bmad-viewer 0.3.2 → 0.3.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmad-viewer",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Visual dashboard for BMAD (Boring Maintainable Agile Development) projects. Wiki browser + sprint status viewer with live reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "url": "https://github.com/CamiloValderramaGonzalez/bmad-viewer/issues"
28
28
  },
29
29
  "dependencies": {
30
+ "bmad-viewer": "^0.3.2",
30
31
  "chokidar": "^4.0.1",
31
32
  "fuse.js": "^7.0.0",
32
33
  "js-yaml": "^4.1.0",
package/public/client.js CHANGED
@@ -491,7 +491,57 @@
491
491
  // Sidebar
492
492
  initSidebarToggles();
493
493
 
494
+ // Path config panel
495
+ initPathConfig();
496
+
494
497
  // WebSocket
495
498
  initWebSocket();
496
499
  });
500
+
501
+ /* ── Path Config Panel ── */
502
+ function initPathConfig() {
503
+ var btn = document.getElementById('apply-paths-btn');
504
+ if (!btn) return;
505
+
506
+ btn.addEventListener('click', function () {
507
+ var epicsInput = document.getElementById('custom-epics-path');
508
+ var outputInput = document.getElementById('custom-output-path');
509
+ var status = document.getElementById('path-config-status');
510
+ var payload = {};
511
+
512
+ if (outputInput && outputInput.value.trim()) payload.outputPath = outputInput.value.trim();
513
+ if (epicsInput && epicsInput.value.trim()) payload.epicsPath = epicsInput.value.trim();
514
+
515
+ if (!payload.outputPath && !payload.epicsPath) {
516
+ if (status) { status.textContent = 'Enter at least one path'; status.className = 'path-config-panel__status path-config-panel__status--err'; }
517
+ return;
518
+ }
519
+
520
+ btn.disabled = true;
521
+ if (status) { status.textContent = 'Applying...'; status.className = 'path-config-panel__status'; }
522
+
523
+ fetch('/api/set-paths', {
524
+ method: 'POST',
525
+ headers: { 'Content-Type': 'application/json' },
526
+ body: JSON.stringify(payload),
527
+ })
528
+ .then(function (r) { return r.json(); })
529
+ .then(function (data) {
530
+ btn.disabled = false;
531
+ if (data.ok) {
532
+ if (status) {
533
+ status.textContent = 'Found ' + data.epics + ' epics, ' + data.stories + ' stories. Reloading...';
534
+ status.className = 'path-config-panel__status path-config-panel__status--ok';
535
+ }
536
+ setTimeout(function () { location.reload(); }, 800);
537
+ } else {
538
+ if (status) { status.textContent = data.error || 'Error applying paths'; status.className = 'path-config-panel__status path-config-panel__status--err'; }
539
+ }
540
+ })
541
+ .catch(function () {
542
+ btn.disabled = false;
543
+ if (status) { status.textContent = 'Network error'; status.className = 'path-config-panel__status path-config-panel__status--err'; }
544
+ });
545
+ });
546
+ }
497
547
  })();
package/public/styles.css CHANGED
@@ -149,6 +149,22 @@ mark.search-highlight{background:rgba(245,158,11,.4);color:var(--text);border-ra
149
149
  /* Warning Banner */
150
150
  .warning-banner{background:rgba(245,158,11,.1);border-bottom:1px solid var(--warning);padding:8px var(--gap)}
151
151
  .warning-banner__item{font-size:.85rem;color:var(--text)}
152
+ /* Path config panel */
153
+ .path-config-panel{background:var(--accent-soft);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:20px}
154
+ .path-config-panel__header h3{font-size:1rem;margin-bottom:4px}
155
+ .path-config-panel__header p{font-size:.85rem;color:var(--text-muted);margin-bottom:16px}
156
+ .path-config-panel__fields{display:flex;flex-direction:column;gap:12px}
157
+ .path-config-panel__label{display:flex;flex-direction:column;gap:4px}
158
+ .path-config-panel__label>span:first-child{font-size:.85rem;font-weight:600}
159
+ .path-config-panel__input{font-family:var(--font-mono);font-size:.85rem;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);width:100%}
160
+ .path-config-panel__input:focus{outline:none;border-color:var(--primary)}
161
+ .path-config-panel__hint{font-size:.75rem;color:var(--text-muted)}
162
+ .path-config-panel__btn{align-self:flex-start;padding:8px 20px;background:var(--primary);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:.85rem;font-weight:600;transition:background .15s}
163
+ .path-config-panel__btn:hover{background:var(--primary-hover)}
164
+ .path-config-panel__btn:disabled{opacity:.6;cursor:not-allowed}
165
+ .path-config-panel__status{font-size:.85rem;color:var(--text-muted);align-self:center}
166
+ .path-config-panel__status--ok{color:var(--success)}
167
+ .path-config-panel__status--err{color:var(--warning)}
152
168
  /* Responsive */
153
169
  @media(max-width:768px){.app-layout{grid-template-columns:1fr}.app-layout__sidebar{max-height:none;position:static;border-right:none;border-bottom:1px solid var(--border)}.kanban{grid-template-columns:1fr}.stats-box{grid-template-columns:repeat(2,1fr)}}
154
170
  @media(max-width:440px){.header-bar__shortcut{display:none}.stats-box{grid-template-columns:1fr}.lens-tabs__tab{padding:8px 12px;font-size:.85rem}}
@@ -8,15 +8,16 @@ import { ErrorAggregator } from '../utils/error-aggregator.js';
8
8
  * Build the complete in-memory data model from a BMAD project.
9
9
  *
10
10
  * @param {string} bmadDir - Project root containing _bmad/
11
+ * @param {{customEpicsPath?: string, customOutputPath?: string}} [options] - Optional overrides
11
12
  * @returns {{wiki: object, project: object, config: object, aggregator: ErrorAggregator}}
12
13
  */
13
- export function buildDataModel(bmadDir) {
14
+ export function buildDataModel(bmadDir, options) {
14
15
  const aggregator = new ErrorAggregator();
15
16
  const bmadPath = join(bmadDir, '_bmad');
16
- const outputPath = join(bmadDir, '_bmad-output');
17
+ const outputPath = options?.customOutputPath || join(bmadDir, '_bmad-output');
17
18
 
18
19
  const wiki = buildWikiData(bmadPath, aggregator);
19
- const project = buildProjectData(outputPath, aggregator);
20
+ const project = buildProjectData(outputPath, aggregator, options?.customEpicsPath);
20
21
  const config = loadConfig(bmadPath, aggregator);
21
22
 
22
23
  return { wiki, project, config, aggregator };
@@ -278,8 +279,11 @@ function readMarkdownSafe(filePath, aggregator) {
278
279
 
279
280
  /**
280
281
  * Build project data from _bmad-output directory.
282
+ * @param {string} outputPath
283
+ * @param {ErrorAggregator} aggregator
284
+ * @param {string} [customEpicsPath] - Optional override path to epics file
281
285
  */
282
- function buildProjectData(outputPath, aggregator) {
286
+ function buildProjectData(outputPath, aggregator, customEpicsPath) {
283
287
  const project = {
284
288
  sprintStatus: null,
285
289
  stories: { total: 0, pending: 0, inProgress: 0, done: 0 },
@@ -288,7 +292,10 @@ function buildProjectData(outputPath, aggregator) {
288
292
  artifacts: [],
289
293
  };
290
294
 
291
- if (!existsSync(outputPath)) return project;
295
+ if (!existsSync(outputPath)) {
296
+ loadCustomEpicsFile(customEpicsPath, project, aggregator);
297
+ return project;
298
+ }
292
299
 
293
300
  // Parse sprint-status.yaml
294
301
  const sprintStatusPaths = [
@@ -369,8 +376,8 @@ function buildProjectData(outputPath, aggregator) {
369
376
  ...content,
370
377
  });
371
378
 
372
- // Parse stories and epics from epics.md
373
- if (name === 'epics' && ext === '.md' && content.raw) {
379
+ // Parse stories and epics from epics.md or epics-and-stories.md
380
+ if (name.startsWith('epics') && ext === '.md' && content.raw) {
374
381
  const storyContents = parseStoriesFromEpics(content.raw, aggregator);
375
382
  project.storyContents = storyContents;
376
383
 
@@ -388,6 +395,11 @@ function buildProjectData(outputPath, aggregator) {
388
395
  }
389
396
  }
390
397
 
398
+ // Load custom epics file if provided and no epics were found from normal scan
399
+ if (project.epics.length === 0) {
400
+ loadCustomEpicsFile(customEpicsPath, project, aggregator);
401
+ }
402
+
391
403
  // Scan implementation artifact story files (direct .md files in impl dir + stories/ subdir)
392
404
  const implDir = join(outputPath, 'implementation-artifacts');
393
405
  if (existsSync(implDir)) {
@@ -502,6 +514,32 @@ function buildProjectData(outputPath, aggregator) {
502
514
  return project;
503
515
  }
504
516
 
517
+ /**
518
+ * Load a custom epics file and populate project data from it.
519
+ */
520
+ function loadCustomEpicsFile(customEpicsPath, project, aggregator) {
521
+ if (!customEpicsPath || !existsSync(customEpicsPath)) return;
522
+ const content = readMarkdownSafe(customEpicsPath, aggregator);
523
+ if (!content.raw) return;
524
+
525
+ const name = basename(customEpicsPath, extname(customEpicsPath));
526
+ project.artifacts.push({
527
+ id: `artifact/${name}`,
528
+ name: formatName(name),
529
+ path: customEpicsPath,
530
+ type: 'planning',
531
+ ...content,
532
+ });
533
+ const storyContents = parseStoriesFromEpics(content.raw, aggregator);
534
+ project.storyContents = storyContents;
535
+ project.epics = parseEpicsFromMarkdown(content.raw, storyContents);
536
+ for (const epic of project.epics) {
537
+ project.stories.total += epic.stories.length;
538
+ project.stories.pending += epic.stories.length;
539
+ project.storyList.push(...epic.stories);
540
+ }
541
+ }
542
+
505
543
  /**
506
544
  * Parse individual story sections from epics.md.
507
545
  * Stories follow the pattern: ### Story X.Y: Title
@@ -509,8 +547,8 @@ function buildProjectData(outputPath, aggregator) {
509
547
  */
510
548
  function parseStoriesFromEpics(raw, aggregator) {
511
549
  const storyMap = {};
512
- // Split on story headers
513
- const storyRegex = /^### Story (\d+)\.(\d+):\s*(.+)$/gm;
550
+ // Split on story headers (supports ### or #### level)
551
+ const storyRegex = /^#{3,4} Story (\d+)\.(\d+):\s*(.+)$/gm;
514
552
  let match;
515
553
  const positions = [];
516
554
 
@@ -556,7 +594,7 @@ function parseStoriesFromEpics(raw, aggregator) {
556
594
  * Stories follow the pattern: ### Story N.M: Title
557
595
  */
558
596
  function parseEpicsFromMarkdown(raw, storyContents) {
559
- const epicRegex = /^## Epic (\d+):\s*(.+)$/gm;
597
+ const epicRegex = /^#{2,3} Epic (\d+):\s*(.+)$/gm;
560
598
  const epicMap = {};
561
599
  let match;
562
600
 
@@ -585,7 +623,7 @@ function parseEpicsFromMarkdown(raw, storyContents) {
585
623
  */
586
624
  function parseEpicNamesFromComments(rawYaml) {
587
625
  const names = {};
588
- const regex = /#\s*Epic\s+(\d+):\s*(.+)/g;
626
+ const regex = /#\s*Epic\s+(\d+):\s*(.+)/gi;
589
627
  let match;
590
628
  while ((match = regex.exec(rawYaml)) !== null) {
591
629
  names[match[1]] = match[2].trim();
@@ -21,8 +21,11 @@ const PUBLIC_DIR = join(__dirname, '..', '..', 'public');
21
21
  export async function startServer({ port, bmadDir, open }) {
22
22
  const actualPort = await findAvailablePort(port);
23
23
 
24
+ // Custom path overrides (can be set via API)
25
+ const overrides = { customEpicsPath: null, customOutputPath: null };
26
+
24
27
  // Build initial data model
25
- let dataModel = buildDataModel(bmadDir);
28
+ let dataModel = buildDataModel(bmadDir, overrides);
26
29
 
27
30
  // Create HTTP server
28
31
  const server = createServer((req, res) => {
@@ -33,10 +36,37 @@ export async function startServer({ port, bmadDir, open }) {
33
36
  res.setHeader('Content-Security-Policy', "default-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:* ws://127.0.0.1:*");
34
37
  res.setHeader('X-Content-Type-Options', 'nosniff');
35
38
 
39
+ // API endpoint: set custom paths
40
+ if (pathname === '/api/set-paths' && req.method === 'POST') {
41
+ let body = '';
42
+ req.on('data', chunk => { body += chunk; });
43
+ req.on('end', () => {
44
+ try {
45
+ const data = JSON.parse(body);
46
+ if (data.epicsPath !== undefined) overrides.customEpicsPath = data.epicsPath || null;
47
+ if (data.outputPath !== undefined) overrides.customOutputPath = data.outputPath || null;
48
+ dataModel = buildDataModel(bmadDir, overrides);
49
+ res.writeHead(200, { 'Content-Type': 'application/json' });
50
+ res.end(JSON.stringify({ ok: true, epics: dataModel.project.epics.length, stories: dataModel.project.stories.total }));
51
+ } catch {
52
+ res.writeHead(400, { 'Content-Type': 'application/json' });
53
+ res.end(JSON.stringify({ ok: false, error: 'Invalid JSON' }));
54
+ }
55
+ });
56
+ return;
57
+ }
58
+
59
+ // API endpoint: get current overrides
60
+ if (pathname === '/api/get-paths') {
61
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
62
+ res.end(JSON.stringify(overrides));
63
+ return;
64
+ }
65
+
36
66
  // API endpoint: get fresh HTML
37
67
  if (pathname === '/api/dashboard') {
38
68
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
39
- dataModel = buildDataModel(bmadDir);
69
+ dataModel = buildDataModel(bmadDir, overrides);
40
70
  const html = renderDashboard(dataModel);
41
71
  res.end(JSON.stringify({ html }));
42
72
  return;
@@ -72,7 +102,7 @@ export async function startServer({ port, bmadDir, open }) {
72
102
 
73
103
  if (debounceTimer) clearTimeout(debounceTimer);
74
104
  debounceTimer = setTimeout(() => {
75
- dataModel = buildDataModel(bmadDir);
105
+ dataModel = buildDataModel(bmadDir, overrides);
76
106
  broadcastChange([...pendingChanges]);
77
107
  pendingChanges.length = 0;
78
108
  }, 150);
@@ -44,8 +44,32 @@ export function renderDashboard(dataModel) {
44
44
  const inProgress = storyList.filter((s) => s.status === 'in-progress');
45
45
  const done = storyList.filter((s) => s.status === 'done' || s.status === 'review');
46
46
 
47
+ const noEpicsNotice = project.epics.length === 0
48
+ ? `<div class="path-config-panel" id="path-config-panel">
49
+ <div class="path-config-panel__header">
50
+ <h3>No epics found</h3>
51
+ <p>Could not auto-detect epics in your project. You can specify custom paths below.</p>
52
+ </div>
53
+ <div class="path-config-panel__fields">
54
+ <label class="path-config-panel__label">
55
+ <span>Output folder</span>
56
+ <input type="text" id="custom-output-path" class="path-config-panel__input" placeholder="e.g. C:\\project\\_bmad-output" />
57
+ <span class="path-config-panel__hint">Folder containing planning-artifacts, implementation-artifacts, etc.</span>
58
+ </label>
59
+ <label class="path-config-panel__label">
60
+ <span>Epics file</span>
61
+ <input type="text" id="custom-epics-path" class="path-config-panel__input" placeholder="e.g. C:\\project\\docs\\epics.md" />
62
+ <span class="path-config-panel__hint">Markdown file with epic/story definitions (## Epic N: / ### Story N.M:)</span>
63
+ </label>
64
+ <button class="path-config-panel__btn" id="apply-paths-btn">Apply</button>
65
+ <span class="path-config-panel__status" id="path-config-status"></span>
66
+ </div>
67
+ </div>`
68
+ : '';
69
+
47
70
  const projectContent = `<div id="project-view" hidden>
48
71
  <div id="project-dashboard">
72
+ ${noEpicsNotice}
49
73
  ${StatsBox({
50
74
  total: project.stories.total,
51
75
  pending: project.stories.pending,