bmad-viewer 0.1.8 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmad-viewer",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
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": {
package/public/client.js CHANGED
@@ -5,6 +5,7 @@
5
5
  var contentMap = window.__BMAD_CONTENT__ || {};
6
6
  var wikiWelcomeHtml = '';
7
7
  var wikiBreadcrumbHtml = '';
8
+ var pendingHighlight = null;
8
9
 
9
10
  /* ── Hash Router ── */
10
11
  function parseHash() {
@@ -82,6 +83,11 @@
82
83
  });
83
84
  breadcrumb.innerHTML = crumbs.join(' <span class="breadcrumb__sep">&rsaquo;</span> ');
84
85
  }
86
+
87
+ if (pendingHighlight) {
88
+ highlightAndScroll(contentBody, pendingHighlight);
89
+ pendingHighlight = null;
90
+ }
85
91
  }
86
92
 
87
93
  function loadProjectContent(id) {
@@ -106,6 +112,62 @@
106
112
  ' <span class="breadcrumb__sep">&rsaquo;</span> ' +
107
113
  '<span class="breadcrumb__current">' + escapeText(item.name) + '</span>';
108
114
  }
115
+
116
+ if (pendingHighlight) {
117
+ highlightAndScroll(contentBody, pendingHighlight);
118
+ pendingHighlight = null;
119
+ }
120
+ }
121
+
122
+ function highlightAndScroll(container, query) {
123
+ var walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false);
124
+ var q = query.toLowerCase();
125
+ var firstMark = null;
126
+
127
+ var nodesToProcess = [];
128
+ while (walker.nextNode()) {
129
+ var node = walker.currentNode;
130
+ if (node.nodeValue && node.nodeValue.toLowerCase().includes(q)) {
131
+ nodesToProcess.push(node);
132
+ }
133
+ }
134
+
135
+ for (var i = 0; i < nodesToProcess.length; i++) {
136
+ var textNode = nodesToProcess[i];
137
+ var text = textNode.nodeValue;
138
+ var idx = text.toLowerCase().indexOf(q);
139
+ if (idx === -1) continue;
140
+
141
+ var before = text.substring(0, idx);
142
+ var match = text.substring(idx, idx + query.length);
143
+ var after = text.substring(idx + query.length);
144
+
145
+ var mark = document.createElement('mark');
146
+ mark.className = 'search-highlight';
147
+ mark.textContent = match;
148
+
149
+ var parent = textNode.parentNode;
150
+ if (before) parent.insertBefore(document.createTextNode(before), textNode);
151
+ parent.insertBefore(mark, textNode);
152
+ if (after) parent.insertBefore(document.createTextNode(after), textNode);
153
+ parent.removeChild(textNode);
154
+
155
+ if (!firstMark) firstMark = mark;
156
+ }
157
+
158
+ if (firstMark) {
159
+ setTimeout(function () {
160
+ firstMark.scrollIntoView({ behavior: 'smooth', block: 'center' });
161
+ }, 50);
162
+
163
+ // Clear highlights after 4 seconds
164
+ setTimeout(function () {
165
+ container.querySelectorAll('mark.search-highlight').forEach(function (el) {
166
+ var txt = document.createTextNode(el.textContent);
167
+ el.parentNode.replaceChild(txt, el);
168
+ });
169
+ }, 4000);
170
+ }
109
171
  }
110
172
 
111
173
  function showWikiWelcome() {
@@ -190,6 +252,18 @@
190
252
  if (modal) modal.hidden = true;
191
253
  }
192
254
 
255
+ // Cache stripped text for content search
256
+ var textCache = {};
257
+ function getPlainText(item) {
258
+ if (!item._cacheKey) item._cacheKey = item.name + '|' + item.type;
259
+ if (textCache[item._cacheKey] !== undefined) return textCache[item._cacheKey];
260
+ var tmp = document.createElement('div');
261
+ tmp.innerHTML = item.html || '';
262
+ var text = (tmp.textContent || tmp.innerText || '').toLowerCase();
263
+ textCache[item._cacheKey] = text;
264
+ return text;
265
+ }
266
+
193
267
  function handleSearch(query) {
194
268
  var results = document.getElementById('search-results');
195
269
  if (!results) return;
@@ -202,20 +276,21 @@
202
276
  var q = query.toLowerCase();
203
277
  var matches = [];
204
278
 
205
- // Search through content map
279
+ // Search through content map — name, type, module, group, and body content
206
280
  for (var id in contentMap) {
207
281
  var item = contentMap[id];
208
282
  var nameMatch = (item.name || '').toLowerCase().includes(q);
209
283
  var typeMatch = (item.type || '').toLowerCase().includes(q);
210
284
  var moduleMatch = (item.module || '').toLowerCase().includes(q);
211
285
  var groupMatch = (item.group || '').toLowerCase().includes(q);
286
+ var contentMatch = !nameMatch && !typeMatch && !moduleMatch && !groupMatch && getPlainText(item).includes(q);
212
287
 
213
- if (nameMatch || typeMatch || moduleMatch || groupMatch) {
214
- matches.push({ id: id, item: item, score: nameMatch ? 1 : 0 });
288
+ if (nameMatch || typeMatch || moduleMatch || groupMatch || contentMatch) {
289
+ matches.push({ id: id, item: item, score: nameMatch ? 2 : (contentMatch ? 0 : 1), contentMatch: contentMatch });
215
290
  }
216
291
  }
217
292
 
218
- // Sort by name match first
293
+ // Sort: name matches first, then metadata, then content
219
294
  matches.sort(function (a, b) { return b.score - a.score; });
220
295
 
221
296
  if (matches.length === 0) {
@@ -233,15 +308,45 @@
233
308
  grouped[type].push(m);
234
309
  });
235
310
 
311
+ var categoryLabels = {
312
+ 'planning': 'Planning', 'research': 'Research', 'analysis': 'Analysis',
313
+ 'test-arch': 'Test Architecture', 'cis': 'CIS Sessions',
314
+ 'bmb-creation': 'BMB Creations', 'diagram': 'Diagrams',
315
+ 'story': 'Stories', 'agent': 'Agents', 'workflow': 'Workflows',
316
+ 'other': 'Other'
317
+ };
318
+
236
319
  var html = '';
237
320
  for (var type in grouped) {
238
- html += '<div class="search-modal__group-label">' + escapeText(type.charAt(0).toUpperCase() + type.slice(1)) + 's</div>';
321
+ var groupLabel = categoryLabels[type] || (type.charAt(0).toUpperCase() + type.slice(1) + 's');
322
+ html += '<div class="search-modal__group-label">' + escapeText(groupLabel) + '</div>';
239
323
  grouped[type].forEach(function (m) {
240
324
  var view = m.id.startsWith('artifact/') || m.id.startsWith('story/') ? 'project' : 'wiki';
325
+ var subtitle = m.item.module
326
+ ? (m.item.module + (m.item.group ? ' > ' + m.item.group : ''))
327
+ : (categoryLabels[m.item.type] || m.item.type || '');
328
+ var snippet = '';
329
+ if (m.contentMatch) {
330
+ var text = getPlainText(m.item);
331
+ var idx = text.indexOf(q);
332
+ if (idx !== -1) {
333
+ var start = Math.max(0, idx - 30);
334
+ var end = Math.min(text.length, idx + q.length + 50);
335
+ var raw = text.substring(start, end).replace(/\s+/g, ' ');
336
+ snippet = '<span class="search-modal__result-snippet">' +
337
+ (start > 0 ? '...' : '') +
338
+ escapeText(raw.substring(0, idx - start)) +
339
+ '<mark>' + escapeText(raw.substring(idx - start, idx - start + q.length)) + '</mark>' +
340
+ escapeText(raw.substring(idx - start + q.length)) +
341
+ (end < text.length ? '...' : '') +
342
+ '</span>';
343
+ }
344
+ }
241
345
  html +=
242
- '<a class="search-modal__result" href="#' + view + '/' + m.id + '">' +
346
+ '<a class="search-modal__result" href="#' + view + '/' + m.id + '"' + (m.contentMatch ? ' data-content-match="1"' : '') + '>' +
243
347
  '<span class="search-modal__result-title">' + escapeText(m.item.name) + '</span>' +
244
- '<span class="search-modal__result-type">' + escapeText((m.item.module || '') + (m.item.group ? ' > ' + m.item.group : '')) + '</span>' +
348
+ '<span class="search-modal__result-type">' + escapeText(subtitle) + '</span>' +
349
+ snippet +
245
350
  '</a>';
246
351
  });
247
352
  }
@@ -367,7 +472,13 @@
367
472
  if (searchResults) {
368
473
  searchResults.addEventListener('click', function (e) {
369
474
  var result = e.target.closest('.search-modal__result');
370
- if (result) closeSearch();
475
+ if (result) {
476
+ if (result.dataset.contentMatch) {
477
+ var input = document.getElementById('search-input');
478
+ pendingHighlight = input ? input.value : null;
479
+ }
480
+ closeSearch();
481
+ }
371
482
  });
372
483
  }
373
484
 
package/public/styles.css CHANGED
@@ -136,10 +136,14 @@ body{font-family:var(--font-family);background:var(--bg);color:var(--text);line-
136
136
  .search-modal__input{width:100%;padding:16px 20px;border:none;border-bottom:1px solid var(--border);background:transparent;font-size:1.1rem;color:var(--text);outline:none;font-family:var(--font-family)}
137
137
  .search-modal__results{max-height:300px;overflow-y:auto}
138
138
  .search-modal__group-label{padding:8px 20px 4px;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted)}
139
- .search-modal__result{display:flex;align-items:center;justify-content:space-between;padding:10px 20px;cursor:pointer;text-decoration:none;color:var(--text)}
139
+ .search-modal__result{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;padding:10px 20px;cursor:pointer;text-decoration:none;color:var(--text);gap:2px 8px}
140
140
  .search-modal__result:hover{background:var(--accent-soft)}
141
141
  .search-modal__result-title{font-weight:500}
142
142
  .search-modal__result-type{font-size:.75rem;color:var(--text-muted)}
143
+ .search-modal__result-snippet{width:100%;font-size:.75rem;color:var(--text-muted);line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
144
+ .search-modal__result-snippet mark{background:rgba(245,158,11,.3);color:var(--text);border-radius:2px;padding:0 1px}
145
+ mark.search-highlight{background:rgba(245,158,11,.4);color:var(--text);border-radius:3px;padding:1px 2px;animation:highlight-fade 4s ease-out forwards}
146
+ @keyframes highlight-fade{0%,70%{background:rgba(245,158,11,.4)}100%{background:transparent}}
143
147
  .search-modal__no-results{padding:20px;text-align:center;color:var(--text-muted);font-size:.9rem}
144
148
  .search-modal__footer{display:flex;gap:16px;padding:10px 20px;border-top:1px solid var(--border);font-size:.75rem;color:var(--text-muted)}
145
149
  /* Warning Banner */
@@ -1,14 +1,25 @@
1
1
  import { escapeHtml } from '../utils/html-escape.js';
2
2
 
3
+ const CATEGORY_ORDER = [
4
+ { key: 'planning', label: 'Planning', icon: '&#128203;' },
5
+ { key: 'research', label: 'Research', icon: '&#128270;' },
6
+ { key: 'analysis', label: 'Analysis', icon: '&#128161;' },
7
+ { key: 'test-arch', label: 'Test Architecture', icon: '&#128295;' },
8
+ { key: 'cis', label: 'CIS Sessions', icon: '&#10024;' },
9
+ { key: 'bmb-creation', label: 'BMB Creations', icon: '&#128296;' },
10
+ { key: 'diagram', label: 'Diagrams', icon: '&#128202;' },
11
+ { key: 'other', label: 'Other', icon: '&#128196;' },
12
+ ];
13
+
3
14
  /**
4
15
  * Render sidebar navigation tree.
5
16
  * Wiki lens: Modules > Groups > Items
6
- * Project lens: Epics with stories + Artifacts section
17
+ * Project lens: Epics with stories + Artifact categories
7
18
  *
8
- * @param {{modules: Array, artifacts: Array, epics: Array}} props
19
+ * @param {{modules: Array, artifacts: Array, epics: Array, artifactGroups: object}} props
9
20
  * @returns {string} HTML string
10
21
  */
11
- export function SidebarNav({ modules, artifacts, epics }) {
22
+ export function SidebarNav({ modules, artifacts, epics, artifactGroups }) {
12
23
  // Wiki sidebar content
13
24
  const modulesList = (modules || [])
14
25
  .map(
@@ -46,7 +57,7 @@ export function SidebarNav({ modules, artifacts, epics }) {
46
57
  )
47
58
  .join('\n');
48
59
 
49
- // Project sidebar: Sprint Dashboard link + Epics with stories + Artifacts
60
+ // Project sidebar: Sprint Dashboard link + Epics with stories + Categorized Artifacts
50
61
  const epicsList = (epics || [])
51
62
  .map(
52
63
  (epic) => `
@@ -74,16 +85,36 @@ export function SidebarNav({ modules, artifacts, epics }) {
74
85
  )
75
86
  .join('\n');
76
87
 
77
- const artifactsList = (artifacts || [])
78
- .map(
79
- (art) => `
80
- <li class="sidebar-nav__item">
81
- <a href="#project/${escapeHtml(art.id)}" class="sidebar-nav__link sidebar-nav__link--artifact" data-id="${escapeHtml(art.id)}">
82
- <span class="sidebar-nav__type-icon">${getArtifactIcon(art.name)}</span>
83
- ${escapeHtml(art.name || 'Untitled')}
84
- </a>
85
- </li>`,
86
- )
88
+ // Build categorized artifact sections
89
+ const groups = artifactGroups || {};
90
+ const categorySections = CATEGORY_ORDER
91
+ .filter((cat) => groups[cat.key] && groups[cat.key].length > 0)
92
+ .map((cat) => {
93
+ const items = groups[cat.key];
94
+ const itemsHtml = items
95
+ .map(
96
+ (art) => `
97
+ <li class="sidebar-nav__item">
98
+ <a href="#project/${escapeHtml(art.id)}" class="sidebar-nav__link sidebar-nav__link--artifact" data-id="${escapeHtml(art.id)}">
99
+ <span class="sidebar-nav__type-icon">${getArtifactIcon(art.name, cat.key)}</span>
100
+ ${escapeHtml(art.name || 'Untitled')}
101
+ </a>
102
+ </li>`,
103
+ )
104
+ .join('\n');
105
+
106
+ return `
107
+ <li class="sidebar-nav__module">
108
+ <button class="sidebar-nav__toggle" aria-expanded="false">
109
+ <span class="sidebar-nav__arrow">&#9656;</span>
110
+ <span class="sidebar-nav__type-icon">${cat.icon}</span>
111
+ ${escapeHtml(cat.label)} <span class="sidebar-nav__count">${items.length}</span>
112
+ </button>
113
+ <ul class="sidebar-nav__items" hidden>
114
+ ${itemsHtml}
115
+ </ul>
116
+ </li>`;
117
+ })
87
118
  .join('\n');
88
119
 
89
120
  return `<nav class="sidebar-nav" aria-label="BMAD Navigation">
@@ -99,8 +130,8 @@ export function SidebarNav({ modules, artifacts, epics }) {
99
130
  </a>
100
131
  ${epicsList ? `<h2 class="sidebar-nav__heading">Epics</h2>
101
132
  <ul class="sidebar-nav__list">${epicsList}</ul>` : ''}
102
- ${artifactsList ? `<h2 class="sidebar-nav__heading">Artifacts</h2>
103
- <ul class="sidebar-nav__list">${artifactsList}</ul>` : ''}
133
+ ${categorySections ? `<h2 class="sidebar-nav__heading">Artifacts</h2>
134
+ <ul class="sidebar-nav__list">${categorySections}</ul>` : ''}
104
135
  </div>
105
136
  </nav>`;
106
137
  }
@@ -118,6 +149,12 @@ function getTypeIcon(type) {
118
149
  case 'story': return '&#128203;';
119
150
  case 'data': return '&#128202;';
120
151
  case 'testarch': return '&#128295;';
152
+ case 'research': return '&#128270;';
153
+ case 'analysis': return '&#128161;';
154
+ case 'test-arch': return '&#128295;';
155
+ case 'cis': return '&#10024;';
156
+ case 'bmb-creation': return '&#128296;';
157
+ case 'diagram': return '&#128202;';
121
158
  default: return '&#128196;';
122
159
  }
123
160
  }
@@ -134,9 +171,19 @@ function getEpicStatusIcon(status) {
134
171
  }
135
172
 
136
173
  /**
137
- * Get icon for artifact by name.
174
+ * Get icon for artifact by name and category.
138
175
  */
139
- function getArtifactIcon(name) {
176
+ function getArtifactIcon(name, category) {
177
+ if (category) {
178
+ switch (category) {
179
+ case 'research': return '&#128270;';
180
+ case 'analysis': return '&#128161;';
181
+ case 'test-arch': return '&#128295;';
182
+ case 'cis': return '&#10024;';
183
+ case 'bmb-creation': return '&#128296;';
184
+ case 'diagram': return '&#128202;';
185
+ }
186
+ }
140
187
  const lower = (name || '').toLowerCase();
141
188
  if (lower.includes('prd')) return '&#128220;';
142
189
  if (lower.includes('architecture')) return '&#127959;';
@@ -1,5 +1,5 @@
1
1
  import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
2
- import { join, extname, basename } from 'node:path';
2
+ import { join, extname, basename, relative } from 'node:path';
3
3
  import { parseYaml } from '../parsers/parse-yaml.js';
4
4
  import { parseMarkdownContent } from '../parsers/parse-markdown.js';
5
5
  import { ErrorAggregator } from '../utils/error-aggregator.js';
@@ -257,22 +257,27 @@ function buildProjectData(outputPath, aggregator) {
257
257
  }
258
258
  }
259
259
 
260
- // Scan planning artifacts
260
+ // Scan planning artifacts (recursive to catch research/ subdir, and include .html files)
261
261
  const planningDir = join(outputPath, 'planning-artifacts');
262
262
  if (existsSync(planningDir)) {
263
- const files = scanDirectMarkdownFiles(planningDir);
263
+ const files = scanFilesRecursive(planningDir, ['.md', '.html']);
264
264
  for (const file of files) {
265
- const name = basename(file, '.md');
266
- const content = readMarkdownSafe(file, aggregator);
265
+ const ext = extname(file).toLowerCase();
266
+ const name = basename(file, ext);
267
+ const content = ext === '.html'
268
+ ? readHtmlSafe(file)
269
+ : readMarkdownSafe(file, aggregator);
270
+ const type = categorizeArtifact(file, outputPath);
267
271
  project.artifacts.push({
268
272
  id: `artifact/${name}`,
269
273
  name: formatName(name),
270
274
  path: file,
275
+ type,
271
276
  ...content,
272
277
  });
273
278
 
274
279
  // Parse stories from epics.md
275
- if (name === 'epics' && content.raw) {
280
+ if (name === 'epics' && ext === '.md' && content.raw) {
276
281
  const storyContents = parseStoriesFromEpics(content.raw, aggregator);
277
282
  project.storyContents = storyContents;
278
283
  }
@@ -282,7 +287,6 @@ function buildProjectData(outputPath, aggregator) {
282
287
  // Scan implementation artifact story files (direct .md files in impl dir + stories/ subdir)
283
288
  const implDir = join(outputPath, 'implementation-artifacts');
284
289
  if (existsSync(implDir)) {
285
- // Scan direct .md files in implementation-artifacts (story files live here)
286
290
  const implFiles = scanDirectMarkdownFiles(implDir);
287
291
  for (const file of implFiles) {
288
292
  const name = basename(file, '.md');
@@ -296,7 +300,6 @@ function buildProjectData(outputPath, aggregator) {
296
300
  });
297
301
  }
298
302
 
299
- // Also check stories/ subdirectory if it exists
300
303
  const storyDir = join(implDir, 'stories');
301
304
  if (existsSync(storyDir)) {
302
305
  const files = scanDirectMarkdownFiles(storyDir);
@@ -314,6 +317,84 @@ function buildProjectData(outputPath, aggregator) {
314
317
  }
315
318
  }
316
319
 
320
+ // Scan analysis/ directory
321
+ const analysisDir = join(outputPath, 'analysis');
322
+ if (existsSync(analysisDir)) {
323
+ const files = scanDirectFiles(analysisDir, ['.md']);
324
+ for (const file of files) {
325
+ const name = basename(file, '.md');
326
+ const content = readMarkdownSafe(file, aggregator);
327
+ project.artifacts.push({
328
+ id: `artifact/${name}`,
329
+ name: formatName(name),
330
+ path: file,
331
+ type: 'analysis',
332
+ ...content,
333
+ });
334
+ }
335
+ }
336
+
337
+ // Scan excalidraw-diagrams/ directory
338
+ const excalidrawDir = join(outputPath, 'excalidraw-diagrams');
339
+ if (existsSync(excalidrawDir)) {
340
+ const files = scanDirectFiles(excalidrawDir, ['.excalidraw']);
341
+ for (const file of files) {
342
+ const name = basename(file, '.excalidraw');
343
+ const content = readExcalidrawSafe(file);
344
+ project.artifacts.push({
345
+ id: `artifact/${name}`,
346
+ name: formatName(name),
347
+ path: file,
348
+ type: 'diagram',
349
+ ...content,
350
+ });
351
+ }
352
+ }
353
+
354
+ // Scan bmb-creations/ directory (recursive, .md + .yaml)
355
+ const bmbDir = join(outputPath, 'bmb-creations');
356
+ if (existsSync(bmbDir)) {
357
+ const files = scanFilesRecursive(bmbDir, ['.md', '.yaml']);
358
+ for (const file of files) {
359
+ const ext = extname(file).toLowerCase();
360
+ const name = basename(file, ext);
361
+ const content = ext === '.yaml'
362
+ ? readYamlSafe(file)
363
+ : readMarkdownSafe(file, aggregator);
364
+ project.artifacts.push({
365
+ id: `artifact/${name}`,
366
+ name: formatName(name),
367
+ path: file,
368
+ type: 'bmb-creation',
369
+ ...content,
370
+ });
371
+ }
372
+ }
373
+
374
+ // Scan root-level files in _bmad-output/ (CIS sessions, test-arch outputs, etc.)
375
+ const rootFiles = scanDirectFiles(outputPath, ['.md']);
376
+ for (const file of rootFiles) {
377
+ const name = basename(file, '.md');
378
+ const content = readMarkdownSafe(file, aggregator);
379
+ const type = categorizeArtifact(file, outputPath);
380
+ project.artifacts.push({
381
+ id: `artifact/${name}`,
382
+ name: formatName(name),
383
+ path: file,
384
+ type,
385
+ ...content,
386
+ });
387
+ }
388
+
389
+ // Build artifactGroups by category (excluding stories which are shown under epics)
390
+ project.artifactGroups = {};
391
+ for (const art of project.artifacts) {
392
+ if (art.type === 'story') continue;
393
+ const cat = art.type || 'other';
394
+ if (!project.artifactGroups[cat]) project.artifactGroups[cat] = [];
395
+ project.artifactGroups[cat].push(art);
396
+ }
397
+
317
398
  return project;
318
399
  }
319
400
 
@@ -448,6 +529,152 @@ function scanDirectMarkdownFiles(dir) {
448
529
  return files.sort();
449
530
  }
450
531
 
532
+ /**
533
+ * Scan a directory for direct files matching given extensions (non-recursive).
534
+ */
535
+ function scanDirectFiles(dir, extensions) {
536
+ const files = [];
537
+ try {
538
+ const entries = readdirSync(dir, { withFileTypes: true });
539
+ for (const entry of entries) {
540
+ if (entry.isFile() && extensions.includes(extname(entry.name).toLowerCase())) {
541
+ files.push(join(dir, entry.name));
542
+ }
543
+ }
544
+ } catch {
545
+ // Ignore
546
+ }
547
+ return files.sort();
548
+ }
549
+
550
+ /**
551
+ * Recursively scan a directory for files matching given extensions.
552
+ */
553
+ function scanFilesRecursive(dir, extensions) {
554
+ const files = [];
555
+ try {
556
+ const entries = readdirSync(dir, { withFileTypes: true });
557
+ for (const entry of entries) {
558
+ const fullPath = join(dir, entry.name);
559
+ if (entry.isDirectory()) {
560
+ files.push(...scanFilesRecursive(fullPath, extensions));
561
+ } else if (entry.isFile() && extensions.includes(extname(entry.name).toLowerCase())) {
562
+ files.push(fullPath);
563
+ }
564
+ }
565
+ } catch {
566
+ // Ignore
567
+ }
568
+ return files.sort();
569
+ }
570
+
571
+ /**
572
+ * Read an Excalidraw file and produce an HTML viewer using the Excalidraw React component via CDN.
573
+ */
574
+ function readExcalidrawSafe(filePath) {
575
+ try {
576
+ const raw = readFileSync(filePath, 'utf8');
577
+ const sceneData = JSON.parse(raw);
578
+
579
+ // Escape the JSON for safe embedding in HTML
580
+ const escapedJson = JSON.stringify(sceneData)
581
+ .replace(/&/g, '&amp;')
582
+ .replace(/</g, '&lt;')
583
+ .replace(/>/g, '&gt;')
584
+ .replace(/"/g, '&quot;');
585
+
586
+ const html = `<div class="excalidraw-viewer" style="width:100%;height:70vh;border:1px solid var(--border-color,#ddd);border-radius:8px;overflow:hidden;">
587
+ <iframe style="width:100%;height:100%;border:none;" srcdoc="<!DOCTYPE html>
588
+ <html>
589
+ <head>
590
+ <meta charset='utf-8'>
591
+ <style>
592
+ * { margin:0; padding:0; box-sizing:border-box; }
593
+ html, body, #root { width:100%; height:100%; }
594
+ .excalidraw .App-menu_top .buttonList { display:none; }
595
+ </style>
596
+ </head>
597
+ <body>
598
+ <div id='root'></div>
599
+ <script src='https://unpkg.com/react@18/umd/react.production.min.js'><\/script>
600
+ <script src='https://unpkg.com/react-dom@18/umd/react-dom.production.min.js'><\/script>
601
+ <script src='https://unpkg.com/@excalidraw/excalidraw/dist/excalidraw.production.min.js'><\/script>
602
+ <script>
603
+ var scene = JSON.parse(decodeURIComponent(&quot;${encodeURIComponent(JSON.stringify(sceneData))}&quot;));
604
+ var App = function() {
605
+ return React.createElement(ExcalidrawLib.Excalidraw, {
606
+ initialData: { elements: scene.elements || [], appState: { viewBackgroundColor: scene.appState?.viewBackgroundColor || '#ffffff', theme: 'light' }, files: scene.files || {} },
607
+ viewModeEnabled: true,
608
+ zenModeEnabled: true,
609
+ gridModeEnabled: false,
610
+ UIOptions: { canvasActions: { changeViewBackgroundColor: false, clearCanvas: false, export: false, loadScene: false, saveToActiveFile: false, toggleTheme: false, saveAsImage: false } }
611
+ });
612
+ };
613
+ ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));
614
+ <\/script>
615
+ </body>
616
+ </html>"></iframe></div>`;
617
+
618
+ return { html, frontmatter: null, raw };
619
+ } catch {
620
+ return { html: '<p>Error loading Excalidraw file</p>', frontmatter: null, raw: '' };
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Read an HTML file and embed it in an isolated iframe.
626
+ */
627
+ function readHtmlSafe(filePath) {
628
+ try {
629
+ const raw = readFileSync(filePath, 'utf8');
630
+ // Escape for srcdoc attribute
631
+ const escaped = raw
632
+ .replace(/&/g, '&amp;')
633
+ .replace(/"/g, '&quot;');
634
+ const html = `<div class="html-artifact-viewer" style="width:100%;height:80vh;border:1px solid var(--border-color,#ddd);border-radius:8px;overflow:hidden;">
635
+ <iframe style="width:100%;height:100%;border:none;" srcdoc="${escaped}"></iframe></div>`;
636
+ return { html, frontmatter: null, raw };
637
+ } catch {
638
+ return { html: '<p>Error loading HTML file</p>', frontmatter: null, raw: '' };
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Read a YAML file and render it as formatted code block.
644
+ */
645
+ function readYamlSafe(filePath) {
646
+ try {
647
+ const raw = readFileSync(filePath, 'utf8');
648
+ const escaped = raw.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
649
+ const html = `<pre><code class="language-yaml">${escaped}</code></pre>`;
650
+ return { html, frontmatter: null, raw };
651
+ } catch {
652
+ return { html: '<p>Error loading YAML file</p>', frontmatter: null, raw: '' };
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Categorize an artifact based on its path relative to _bmad-output.
658
+ */
659
+ function categorizeArtifact(filePath, outputPath) {
660
+ const rel = relative(outputPath, filePath).replace(/\\/g, '/');
661
+
662
+ if (rel.startsWith('planning-artifacts/research/')) return 'research';
663
+ if (rel.startsWith('planning-artifacts/')) return 'planning';
664
+ if (rel.startsWith('implementation-artifacts/')) return 'story';
665
+ if (rel.startsWith('analysis/')) return 'analysis';
666
+ if (rel.startsWith('excalidraw-diagrams/')) return 'diagram';
667
+ if (rel.startsWith('bmb-creations/')) return 'bmb-creation';
668
+
669
+ // Root-level files — categorize by filename prefix
670
+ const name = basename(filePath).toLowerCase();
671
+ if (/^(test-design|test-review|atdd-|automation-|traceability-|gate-decision-|nfr-)/.test(name)) return 'test-arch';
672
+ if (/^(design-thinking-|innovation-strategy-|problem-solution-|story-)/.test(name)) return 'cis';
673
+ if (/^brainstorming-/.test(name)) return 'analysis';
674
+
675
+ return 'other';
676
+ }
677
+
451
678
  /**
452
679
  * Format a kebab-case name to Title Case.
453
680
  */
@@ -18,6 +18,7 @@ export function renderDashboard(dataModel) {
18
18
  modules: wiki.modules,
19
19
  artifacts: project.artifacts,
20
20
  epics: project.epics,
21
+ artifactGroups: project.artifactGroups,
21
22
  });
22
23
 
23
24
  // Build content data JSON for client-side rendering