mviz 1.4.2

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 (194) hide show
  1. package/README.md +174 -0
  2. package/dist/charts/area.d.ts +14 -0
  3. package/dist/charts/area.d.ts.map +1 -0
  4. package/dist/charts/area.js +137 -0
  5. package/dist/charts/area.js.map +1 -0
  6. package/dist/charts/bar.d.ts +14 -0
  7. package/dist/charts/bar.d.ts.map +1 -0
  8. package/dist/charts/bar.js +191 -0
  9. package/dist/charts/bar.js.map +1 -0
  10. package/dist/charts/boxplot.d.ts +14 -0
  11. package/dist/charts/boxplot.d.ts.map +1 -0
  12. package/dist/charts/boxplot.js +79 -0
  13. package/dist/charts/boxplot.js.map +1 -0
  14. package/dist/charts/bubble.d.ts +14 -0
  15. package/dist/charts/bubble.d.ts.map +1 -0
  16. package/dist/charts/bubble.js +127 -0
  17. package/dist/charts/bubble.js.map +1 -0
  18. package/dist/charts/calendar.d.ts +14 -0
  19. package/dist/charts/calendar.d.ts.map +1 -0
  20. package/dist/charts/calendar.js +94 -0
  21. package/dist/charts/calendar.js.map +1 -0
  22. package/dist/charts/combo.d.ts +14 -0
  23. package/dist/charts/combo.d.ts.map +1 -0
  24. package/dist/charts/combo.js +163 -0
  25. package/dist/charts/combo.js.map +1 -0
  26. package/dist/charts/dumbbell.d.ts +17 -0
  27. package/dist/charts/dumbbell.d.ts.map +1 -0
  28. package/dist/charts/dumbbell.js +368 -0
  29. package/dist/charts/dumbbell.js.map +1 -0
  30. package/dist/charts/funnel.d.ts +14 -0
  31. package/dist/charts/funnel.d.ts.map +1 -0
  32. package/dist/charts/funnel.js +145 -0
  33. package/dist/charts/funnel.js.map +1 -0
  34. package/dist/charts/heatmap.d.ts +14 -0
  35. package/dist/charts/heatmap.d.ts.map +1 -0
  36. package/dist/charts/heatmap.js +202 -0
  37. package/dist/charts/heatmap.js.map +1 -0
  38. package/dist/charts/histogram.d.ts +14 -0
  39. package/dist/charts/histogram.d.ts.map +1 -0
  40. package/dist/charts/histogram.js +103 -0
  41. package/dist/charts/histogram.js.map +1 -0
  42. package/dist/charts/index.d.ts +40 -0
  43. package/dist/charts/index.d.ts.map +1 -0
  44. package/dist/charts/index.js +42 -0
  45. package/dist/charts/index.js.map +1 -0
  46. package/dist/charts/line.d.ts +14 -0
  47. package/dist/charts/line.d.ts.map +1 -0
  48. package/dist/charts/line.js +134 -0
  49. package/dist/charts/line.js.map +1 -0
  50. package/dist/charts/pie.d.ts +14 -0
  51. package/dist/charts/pie.d.ts.map +1 -0
  52. package/dist/charts/pie.js +75 -0
  53. package/dist/charts/pie.js.map +1 -0
  54. package/dist/charts/registry.d.ts +36 -0
  55. package/dist/charts/registry.d.ts.map +1 -0
  56. package/dist/charts/registry.js +55 -0
  57. package/dist/charts/registry.js.map +1 -0
  58. package/dist/charts/sankey.d.ts +14 -0
  59. package/dist/charts/sankey.d.ts.map +1 -0
  60. package/dist/charts/sankey.js +74 -0
  61. package/dist/charts/sankey.js.map +1 -0
  62. package/dist/charts/scatter.d.ts +14 -0
  63. package/dist/charts/scatter.d.ts.map +1 -0
  64. package/dist/charts/scatter.js +130 -0
  65. package/dist/charts/scatter.js.map +1 -0
  66. package/dist/charts/sparkline.d.ts +19 -0
  67. package/dist/charts/sparkline.d.ts.map +1 -0
  68. package/dist/charts/sparkline.js +154 -0
  69. package/dist/charts/sparkline.js.map +1 -0
  70. package/dist/charts/waterfall.d.ts +14 -0
  71. package/dist/charts/waterfall.d.ts.map +1 -0
  72. package/dist/charts/waterfall.js +232 -0
  73. package/dist/charts/waterfall.js.map +1 -0
  74. package/dist/charts/xmr.d.ts +14 -0
  75. package/dist/charts/xmr.d.ts.map +1 -0
  76. package/dist/charts/xmr.js +456 -0
  77. package/dist/charts/xmr.js.map +1 -0
  78. package/dist/cli.d.ts +12 -0
  79. package/dist/cli.d.ts.map +1 -0
  80. package/dist/cli.js +120 -0
  81. package/dist/cli.js.map +1 -0
  82. package/dist/components/alert.d.ts +10 -0
  83. package/dist/components/alert.d.ts.map +1 -0
  84. package/dist/components/alert.js +65 -0
  85. package/dist/components/alert.js.map +1 -0
  86. package/dist/components/big_value.d.ts +10 -0
  87. package/dist/components/big_value.d.ts.map +1 -0
  88. package/dist/components/big_value.js +78 -0
  89. package/dist/components/big_value.js.map +1 -0
  90. package/dist/components/delta.d.ts +10 -0
  91. package/dist/components/delta.d.ts.map +1 -0
  92. package/dist/components/delta.js +83 -0
  93. package/dist/components/delta.js.map +1 -0
  94. package/dist/components/empty_space.d.ts +10 -0
  95. package/dist/components/empty_space.d.ts.map +1 -0
  96. package/dist/components/empty_space.js +29 -0
  97. package/dist/components/empty_space.js.map +1 -0
  98. package/dist/components/index.d.ts +21 -0
  99. package/dist/components/index.d.ts.map +1 -0
  100. package/dist/components/index.js +23 -0
  101. package/dist/components/index.js.map +1 -0
  102. package/dist/components/note.d.ts +10 -0
  103. package/dist/components/note.d.ts.map +1 -0
  104. package/dist/components/note.js +66 -0
  105. package/dist/components/note.js.map +1 -0
  106. package/dist/components/registry.d.ts +24 -0
  107. package/dist/components/registry.d.ts.map +1 -0
  108. package/dist/components/registry.js +36 -0
  109. package/dist/components/registry.js.map +1 -0
  110. package/dist/components/table.d.ts +90 -0
  111. package/dist/components/table.d.ts.map +1 -0
  112. package/dist/components/table.js +610 -0
  113. package/dist/components/table.js.map +1 -0
  114. package/dist/components/text.d.ts +10 -0
  115. package/dist/components/text.d.ts.map +1 -0
  116. package/dist/components/text.js +46 -0
  117. package/dist/components/text.js.map +1 -0
  118. package/dist/components/textarea.d.ts +10 -0
  119. package/dist/components/textarea.d.ts.map +1 -0
  120. package/dist/components/textarea.js +79 -0
  121. package/dist/components/textarea.js.map +1 -0
  122. package/dist/core/colors.d.ts +45 -0
  123. package/dist/core/colors.d.ts.map +1 -0
  124. package/dist/core/colors.js +93 -0
  125. package/dist/core/colors.js.map +1 -0
  126. package/dist/core/css.d.ts +20 -0
  127. package/dist/core/css.d.ts.map +1 -0
  128. package/dist/core/css.js +97 -0
  129. package/dist/core/css.js.map +1 -0
  130. package/dist/core/exceptions.d.ts +59 -0
  131. package/dist/core/exceptions.d.ts.map +1 -0
  132. package/dist/core/exceptions.js +100 -0
  133. package/dist/core/exceptions.js.map +1 -0
  134. package/dist/core/formatting.d.ts +53 -0
  135. package/dist/core/formatting.d.ts.map +1 -0
  136. package/dist/core/formatting.js +491 -0
  137. package/dist/core/formatting.js.map +1 -0
  138. package/dist/core/index.d.ts +10 -0
  139. package/dist/core/index.d.ts.map +1 -0
  140. package/dist/core/index.js +10 -0
  141. package/dist/core/index.js.map +1 -0
  142. package/dist/core/serializer.d.ts +29 -0
  143. package/dist/core/serializer.d.ts.map +1 -0
  144. package/dist/core/serializer.js +84 -0
  145. package/dist/core/serializer.js.map +1 -0
  146. package/dist/core/themes.d.ts +138 -0
  147. package/dist/core/themes.d.ts.map +1 -0
  148. package/dist/core/themes.js +484 -0
  149. package/dist/core/themes.js.map +1 -0
  150. package/dist/core/version-check.d.ts +23 -0
  151. package/dist/core/version-check.d.ts.map +1 -0
  152. package/dist/core/version-check.js +163 -0
  153. package/dist/core/version-check.js.map +1 -0
  154. package/dist/generate_test_harness.d.ts +13 -0
  155. package/dist/generate_test_harness.d.ts.map +1 -0
  156. package/dist/generate_test_harness.js +35 -0
  157. package/dist/generate_test_harness.js.map +1 -0
  158. package/dist/index.d.ts +17 -0
  159. package/dist/index.d.ts.map +1 -0
  160. package/dist/index.js +19 -0
  161. package/dist/index.js.map +1 -0
  162. package/dist/layout/converter.d.ts +22 -0
  163. package/dist/layout/converter.d.ts.map +1 -0
  164. package/dist/layout/converter.js +46 -0
  165. package/dist/layout/converter.js.map +1 -0
  166. package/dist/layout/csv.d.ts +15 -0
  167. package/dist/layout/csv.d.ts.map +1 -0
  168. package/dist/layout/csv.js +88 -0
  169. package/dist/layout/csv.js.map +1 -0
  170. package/dist/layout/dispatcher.d.ts +13 -0
  171. package/dist/layout/dispatcher.d.ts.map +1 -0
  172. package/dist/layout/dispatcher.js +47 -0
  173. package/dist/layout/dispatcher.js.map +1 -0
  174. package/dist/layout/index.d.ts +8 -0
  175. package/dist/layout/index.d.ts.map +1 -0
  176. package/dist/layout/index.js +8 -0
  177. package/dist/layout/index.js.map +1 -0
  178. package/dist/layout/parser.d.ts +19 -0
  179. package/dist/layout/parser.d.ts.map +1 -0
  180. package/dist/layout/parser.js +888 -0
  181. package/dist/layout/parser.js.map +1 -0
  182. package/dist/layout/templates.d.ts +32 -0
  183. package/dist/layout/templates.d.ts.map +1 -0
  184. package/dist/layout/templates.js +1016 -0
  185. package/dist/layout/templates.js.map +1 -0
  186. package/dist/types.d.ts +144 -0
  187. package/dist/types.d.ts.map +1 -0
  188. package/dist/types.js +5 -0
  189. package/dist/types.js.map +1 -0
  190. package/dist/vitest.config.d.ts +3 -0
  191. package/dist/vitest.config.d.ts.map +1 -0
  192. package/dist/vitest.config.js +14 -0
  193. package/dist/vitest.config.js.map +1 -0
  194. package/package.json +79 -0
@@ -0,0 +1,888 @@
1
+ /**
2
+ * Markdown dashboard parser for mviz
3
+ *
4
+ * Parses markdown files with embedded chart/component specs and generates HTML.
5
+ */
6
+ import { readFileSync, existsSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ import { generateDashboardHtml, generateTestHarnessHtml, generateTestHarnessJs } from './templates.js';
9
+ import { convertColumnarFormat } from './converter.js';
10
+ import { parseCsv } from './csv.js';
11
+ import { getOptionsBuilder } from '../charts/index.js';
12
+ import { serializeOption } from '../core/serializer.js';
13
+ import { formatNumber, inferFormat } from '../core/formatting.js';
14
+ import { COLORS, GRID_TOTAL_COLUMNS, DEFAULT_SIZES, autoSizeChart, getHeatmapColors, getThemeColors, ALERT_ICONS, } from '../core/themes.js';
15
+ import { formatCell, computeHeatmapRanges, computeDumbbellRanges, } from '../components/table.js';
16
+ // Height per row unit (compact for print) - must match Python's ROW_HEIGHT_PX
17
+ const ROW_HEIGHT_PX = 32;
18
+ // Minimum column span for auto-sized items
19
+ const MIN_AUTO_COLUMN_SPAN = 4;
20
+ /**
21
+ * Parse YAML-like frontmatter from markdown
22
+ */
23
+ function parseFrontmatter(lines, baseTheme) {
24
+ let theme = baseTheme;
25
+ let pageTitle = 'Dashboard';
26
+ let continuous = false;
27
+ if (lines.length === 0 || lines[0] !== '---') {
28
+ return { theme, pageTitle, continuous, remainingLines: lines };
29
+ }
30
+ let frontmatterEnd = -1;
31
+ for (let i = 1; i < lines.length; i++) {
32
+ if (lines[i] === '---') {
33
+ frontmatterEnd = i;
34
+ break;
35
+ }
36
+ }
37
+ if (frontmatterEnd <= 0) {
38
+ return { theme, pageTitle, continuous, remainingLines: lines };
39
+ }
40
+ for (let i = 1; i < frontmatterEnd; i++) {
41
+ const line = lines[i];
42
+ const colonIdx = line.indexOf(':');
43
+ if (colonIdx > 0) {
44
+ const key = line.slice(0, colonIdx).trim();
45
+ let val = line.slice(colonIdx + 1).trim();
46
+ // Strip surrounding quotes
47
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
48
+ val = val.slice(1, -1);
49
+ }
50
+ if (key === 'theme') {
51
+ theme = val;
52
+ }
53
+ else if (key === 'title') {
54
+ pageTitle = val;
55
+ }
56
+ else if (key === 'continuous') {
57
+ continuous = ['true', 'yes', '1'].includes(val.toLowerCase());
58
+ }
59
+ }
60
+ }
61
+ return { theme, pageTitle, continuous, remainingLines: lines.slice(frontmatterEnd + 1) };
62
+ }
63
+ /**
64
+ * Resolve auto sizes in a row based on row composition
65
+ */
66
+ function resolveAutoSizes(row) {
67
+ const autoItems = [];
68
+ let fixedCols = 0;
69
+ for (const item of row) {
70
+ if (item.size === 'auto') {
71
+ autoItems.push(item);
72
+ }
73
+ else {
74
+ const size = item.size ?? DEFAULT_SIZES[item.type] ?? [8, 4];
75
+ fixedCols += size[0];
76
+ }
77
+ }
78
+ if (autoItems.length === 0)
79
+ return;
80
+ const remainingCols = Math.max(0, GRID_TOTAL_COLUMNS - fixedCols);
81
+ const colsPerAuto = autoItems.length > 0
82
+ ? Math.max(MIN_AUTO_COLUMN_SPAN, Math.floor(remainingCols / autoItems.length))
83
+ : MIN_AUTO_COLUMN_SPAN * 2;
84
+ for (const item of autoItems) {
85
+ const [autoCols, autoRows] = autoSizeChart(item.type, item.spec);
86
+ if (autoItems.length === 1) {
87
+ item.size = [autoCols, autoRows];
88
+ }
89
+ else {
90
+ item.size = [Math.min(autoCols, colsPerAuto), autoRows];
91
+ }
92
+ }
93
+ }
94
+ /**
95
+ * Generate ECharts initialization script for a chart
96
+ */
97
+ function generateChartScript(chartId, optionJsonLight, optionJsonDark) {
98
+ const darkOptions = optionJsonDark ?? optionJsonLight;
99
+ return `
100
+ (function() {
101
+ var chart = echarts.init(document.getElementById('${chartId}'), null, {renderer: 'svg'});
102
+ window.chartInstances = window.chartInstances || {};
103
+ window.chartOptions = window.chartOptions || {};
104
+ window.chartInstances['${chartId}'] = chart;
105
+ window.chartOptions['${chartId}'] = {
106
+ light: ${optionJsonLight},
107
+ dark: ${darkOptions}
108
+ };
109
+ var currentTheme = document.body.classList.contains('theme-dark') ? 'dark' : 'light';
110
+ chart.setOption(window.chartOptions['${chartId}'][currentTheme]);
111
+ window.addEventListener('resize', function() { chart.resize(); });
112
+ })();`;
113
+ }
114
+ /**
115
+ * Render an ECharts component
116
+ */
117
+ function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, anchorId) {
118
+ const title = spec.title ?? '';
119
+ const titleHtml = title ? `<h3 class="chart-title">${escapeHtml(title)}</h3>` : '';
120
+ const builder = getOptionsBuilder(compType);
121
+ if (!builder) {
122
+ return { html: null, script: null };
123
+ }
124
+ const chartHeight = itemHeight - (title ? 24 : 0);
125
+ // Generate light theme options
126
+ const specLight = { ...spec, theme: 'light', height: chartHeight };
127
+ const optionLight = builder(specLight);
128
+ if (!optionLight) {
129
+ return { html: null, script: null };
130
+ }
131
+ const optionJsonLight = serializeOption(optionLight);
132
+ // Generate dark theme options
133
+ const specDark = { ...spec, theme: 'dark', height: chartHeight };
134
+ const optionDark = builder(specDark);
135
+ const optionJsonDark = optionDark ? serializeOption(optionDark) : null;
136
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
137
+ const html = `
138
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
139
+ ${titleHtml}
140
+ <div id="${chartId}" style="width: 100%; height: ${chartHeight}px;"></div>
141
+ </div>`;
142
+ const script = generateChartScript(chartId, optionJsonLight, optionJsonDark);
143
+ return { html, script };
144
+ }
145
+ /**
146
+ * Escape HTML special characters
147
+ */
148
+ function escapeHtml(text) {
149
+ return text
150
+ .replace(/&/g, '&amp;')
151
+ .replace(/</g, '&lt;')
152
+ .replace(/>/g, '&gt;')
153
+ .replace(/"/g, '&quot;')
154
+ .replace(/'/g, '&#039;');
155
+ }
156
+ /**
157
+ * Simple component renderers
158
+ */
159
+ function renderBigValue(spec, colSpan, anchorId) {
160
+ const value = typeof spec.value === 'number' ? spec.value : 0;
161
+ const specLabel = spec.label;
162
+ const specTitle = spec.title;
163
+ // If only title is provided (no label), use title as the label below the number
164
+ // If both provided, title becomes H2 header and label goes below
165
+ const label = specLabel ?? specTitle ?? '';
166
+ const showTitleHeader = specTitle && specLabel;
167
+ const fmt = spec.format ?? inferFormat(label, value);
168
+ const comparison = spec.comparison;
169
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
170
+ const displayValue = formatNumber(value, fmt);
171
+ // Build comparison HTML if provided
172
+ let comparisonHtml = '';
173
+ if (comparison) {
174
+ const compVal = comparison.value ?? 0;
175
+ const compLabel = comparison.label ?? '';
176
+ const compFmt = comparison.format;
177
+ const isPositive = compVal >= 0;
178
+ const arrow = isPositive ? '↑' : '↓';
179
+ const compColor = isPositive ? COLORS.POSITIVE_GREEN : COLORS.ERROR_RED;
180
+ const compDisplay = formatNumber(compVal, compFmt, true);
181
+ comparisonHtml = `
182
+ <div style="display: flex; align-items: center; gap: 4px; margin-top: 4px;">
183
+ <span style="color: ${compColor}; font-size: 12px;">${arrow} ${compDisplay}</span>
184
+ <span style="color: var(--text-muted); font-size: 10px;">${escapeHtml(compLabel)}</span>
185
+ </div>`;
186
+ }
187
+ // Build title header HTML if both title and label provided
188
+ const titleHtml = showTitleHeader
189
+ ? `<h3 class="chart-title">${escapeHtml(specTitle.toUpperCase())}</h3>`
190
+ : '';
191
+ return `
192
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
193
+ ${titleHtml}<div class="big-value">${escapeHtml(displayValue)}</div>
194
+ <div class="label">${escapeHtml(label)}</div>${comparisonHtml}
195
+ </div>`;
196
+ }
197
+ function renderNote(spec, colSpan, anchorId) {
198
+ const content = spec.content ?? '';
199
+ const label = spec.label ?? '';
200
+ // Support both 'noteType' (Python style) and 'variant' for compatibility
201
+ const noteType = spec.noteType ?? spec.variant ?? 'default';
202
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
203
+ // Parse inline markdown in content
204
+ const parsedContent = parseInlineMarkdown(content);
205
+ const contentHtml = label ? `<strong>${escapeHtml(label)}:</strong> ${parsedContent}` : parsedContent;
206
+ return `
207
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
208
+ <div class="note note-${noteType}">
209
+ <div class="note-content">${contentHtml}</div>
210
+ </div>
211
+ </div>`;
212
+ }
213
+ function renderText(spec, colSpan, anchorId) {
214
+ const content = spec.content ?? '';
215
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
216
+ return `
217
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
218
+ <div class="text-content">${escapeHtml(content)}</div>
219
+ </div>`;
220
+ }
221
+ function renderEmptySpace(_spec, colSpan, anchorId) {
222
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
223
+ return `
224
+ <div class="grid-item empty-space"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
225
+ </div>`;
226
+ }
227
+ function renderInlineHeader(spec, anchorId) {
228
+ const text = spec.text ?? '';
229
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
230
+ return `
231
+ <div class="inline-header"${anchorAttr} style="--col-span: ${GRID_TOTAL_COLUMNS}; grid-column: span ${GRID_TOTAL_COLUMNS};">
232
+ <p class="inline-header-text">${escapeHtml(text)}</p>
233
+ </div>`;
234
+ }
235
+ function renderDelta(spec, colSpan, anchorId) {
236
+ const value = typeof spec.value === 'number' ? spec.value : 0;
237
+ const specLabel = spec.label;
238
+ const specTitle = spec.title;
239
+ // If only title is provided (no label), use title as the label below
240
+ // If both provided, title becomes header and label goes below
241
+ const label = specLabel ?? specTitle ?? '';
242
+ const showTitleHeader = specTitle && specLabel;
243
+ const fmt = spec.format ?? inferFormat(label, value);
244
+ const positiveIsGood = spec.positiveIsGood !== false; // Default true
245
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
246
+ const isPositive = value > 0;
247
+ const isGood = (isPositive && positiveIsGood) || (!isPositive && !positiveIsGood);
248
+ const color = isGood ? COLORS.POSITIVE_GREEN : COLORS.ERROR_RED;
249
+ const arrow = isPositive ? '↑' : '↓';
250
+ const displayValue = formatNumber(value, fmt, true);
251
+ // Build title header HTML if both title and label provided
252
+ const titleHtml = showTitleHeader
253
+ ? `<h3 class="chart-title">${escapeHtml(specTitle.toUpperCase())}</h3>`
254
+ : '';
255
+ return `
256
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
257
+ ${titleHtml}<div class="delta">
258
+ <span class="arrow" style="color: ${color};">${arrow}</span>
259
+ <span class="value" style="color: ${color};">${escapeHtml(displayValue)}</span>
260
+ </div>
261
+ <div class="label">${escapeHtml(label)}</div>
262
+ </div>`;
263
+ }
264
+ // Alert type configuration
265
+ const ALERT_COLORS = {
266
+ info: COLORS.INFO_BLUE,
267
+ success: COLORS.POSITIVE_GREEN,
268
+ warning: COLORS.WARNING_AMBER,
269
+ error: COLORS.ERROR_RED,
270
+ };
271
+ function renderAlert(spec, colSpan, anchorId) {
272
+ const message = spec.message ?? '';
273
+ const alertType = spec.alertType ?? 'info';
274
+ const alertColor = ALERT_COLORS[alertType] ?? COLORS.INFO_BLUE;
275
+ const icon = ALERT_ICONS[alertType] ?? 'ℹ';
276
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
277
+ // Parse inline markdown (bold, italic)
278
+ const parsedMessage = parseInlineMarkdown(message);
279
+ return `
280
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
281
+ <div class="alert" style="border-left-color: ${alertColor}; background-color: ${alertColor}1a;">
282
+ <span class="icon" style="color: ${alertColor};">${icon}</span>
283
+ <span class="message">${parsedMessage}</span>
284
+ </div>
285
+ </div>`;
286
+ }
287
+ function renderTextarea(spec, colSpan, anchorId) {
288
+ const content = spec.content ?? '';
289
+ const title = spec.title ?? '';
290
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
291
+ const titleHtml = title ? `<h3 class="chart-title">${escapeHtml(title)}</h3>` : '';
292
+ // Escape content for safe embedding in JavaScript
293
+ const escapedContent = content.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
294
+ // Generate unique ID based on content hash
295
+ const contentId = `md-${Math.abs(hashString(content)) % 10000000}`;
296
+ return `
297
+ <div class="grid-item textarea-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
298
+ ${titleHtml}
299
+ <div class="markdown-content" id="${contentId}"></div>
300
+ <script>
301
+ document.getElementById('${contentId}').innerHTML = marked.parse(\`${escapedContent}\`);
302
+ </script>
303
+ </div>`;
304
+ }
305
+ function renderPctBarSparkline(spec, colSpan, anchorId) {
306
+ const title = spec.title ?? '';
307
+ const pctValue = typeof spec.value === 'number' ? spec.value : 0;
308
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
309
+ // Normalize to 0-1 range and clamp
310
+ const pct = pctValue <= 1 ? pctValue : pctValue / 100;
311
+ const clampedPct = Math.max(0, Math.min(1, pct));
312
+ const widthPct = clampedPct * 100;
313
+ const displayValue = `${Math.round(clampedPct * 100)}%`;
314
+ const titleHtml = title ? `<h3 class="chart-title">${escapeHtml(title)}</h3>` : '';
315
+ return `
316
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
317
+ ${titleHtml}
318
+ <div class="pct-bar-wrapper">
319
+ <div class="pct-bar-container">
320
+ <div class="pct-bar-fill" style="width: ${widthPct}%;"></div>
321
+ </div>
322
+ <span class="pct-bar-value">${displayValue}</span>
323
+ </div>
324
+ </div>`;
325
+ }
326
+ function renderTable(spec, colSpan, anchorId, baseTheme) {
327
+ const data = spec.data ?? [];
328
+ const title = spec.title ?? '';
329
+ const striped = spec.striped === true;
330
+ const compact = spec.compact === true;
331
+ const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
332
+ // Auto-detect columns from data if not specified
333
+ let columns = spec.columns;
334
+ if (!columns && data.length > 0) {
335
+ const firstRow = data[0];
336
+ if (firstRow) {
337
+ columns = Object.keys(firstRow).map((key) => ({ id: key, title: key }));
338
+ }
339
+ }
340
+ if (!columns)
341
+ columns = [];
342
+ const cellPadding = compact ? '3px 6px' : '5px 8px';
343
+ const headerPadding = compact ? '3px 6px' : '6px 8px';
344
+ const fontSize = compact ? '10px' : '11px';
345
+ const compactClass = compact ? ' table-compact' : '';
346
+ const stripedClass = striped ? ' table-striped' : '';
347
+ // Pre-compute ranges for heatmap and dumbbell
348
+ const defaultHeatmapColors = getHeatmapColors(baseTheme);
349
+ const heatmapRanges = computeHeatmapRanges(columns, data);
350
+ const dumbbellRanges = computeDumbbellRanges(columns, data);
351
+ const themeColors = getThemeColors(baseTheme);
352
+ // Build header
353
+ const headerCells = columns.map((col) => {
354
+ const align = col.align ?? 'left';
355
+ return `<th style="text-align: ${align}; padding: ${headerPadding}; font-size: ${fontSize};">${escapeHtml(col.title ?? col.id)}</th>`;
356
+ }).join('');
357
+ // Build rows
358
+ const bodyRows = data.map((row, _rowIdx) => {
359
+ const cells = columns.map((col) => {
360
+ const cellValue = row[col.id];
361
+ const align = col.align ?? 'left';
362
+ const result = formatCell(cellValue, col, themeColors, heatmapRanges, dumbbellRanges, defaultHeatmapColors);
363
+ const bgStyle = result.bgColor ? `background-color: ${result.bgColor};` : '';
364
+ const textStyle = result.textColor ? `color: ${result.textColor};` : '';
365
+ return `<td style="text-align: ${align}; padding: ${cellPadding}; font-size: ${fontSize}; ${bgStyle} ${textStyle}">${result.content}</td>`;
366
+ }).join('');
367
+ return `<tr>${cells}</tr>`;
368
+ }).join('');
369
+ const titleHtml = title ? `<h3 class="chart-title">${escapeHtml(title)}</h3>` : '';
370
+ return `
371
+ <div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
372
+ ${titleHtml}
373
+ <table class="data-table${compactClass}${stripedClass}" style="width: 100%; border-collapse: collapse; font-size: ${fontSize};">
374
+ <thead>
375
+ <tr>${headerCells}</tr>
376
+ </thead>
377
+ <tbody>
378
+ ${bodyRows}
379
+ </tbody>
380
+ </table>
381
+ </div>`;
382
+ }
383
+ // Helper function to parse inline markdown
384
+ function parseInlineMarkdown(text) {
385
+ // Bold: **text** or __text__
386
+ let result = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
387
+ result = result.replace(/__([^_]+)__/g, '<strong>$1</strong>');
388
+ // Italic: *text* or _text_
389
+ result = result.replace(/\*([^*]+)\*/g, '<em>$1</em>');
390
+ result = result.replace(/_([^_]+)_/g, '<em>$1</em>');
391
+ // Code: `text`
392
+ result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
393
+ return result;
394
+ }
395
+ // Simple hash function for generating unique IDs
396
+ function hashString(str) {
397
+ let hash = 0;
398
+ for (let i = 0; i < str.length; i++) {
399
+ const char = str.charCodeAt(i);
400
+ hash = ((hash << 5) - hash) + char;
401
+ hash = hash & hash; // Convert to 32bit integer
402
+ }
403
+ return hash;
404
+ }
405
+ /**
406
+ * Parse markdown table syntax into a table spec
407
+ */
408
+ function parseMarkdownTable(tableLines, theme) {
409
+ if (tableLines.length < 2) {
410
+ return null;
411
+ }
412
+ // Parse header row
413
+ let headerLine = tableLines[0].trim();
414
+ if (!headerLine.startsWith('|') || !headerLine.endsWith('|')) {
415
+ headerLine = '|' + headerLine + '|';
416
+ }
417
+ const headers = headerLine.split('|').slice(1, -1).map(h => h.trim());
418
+ if (headers.length === 0) {
419
+ return null;
420
+ }
421
+ // Parse separator row and extract alignments
422
+ let alignments = [];
423
+ if (tableLines.length > 1) {
424
+ const sepLine = tableLines[1].trim();
425
+ if (!sepLine.includes('-')) {
426
+ return null;
427
+ }
428
+ const sepParts = sepLine.split('|').slice(1, -1).map(s => s.trim());
429
+ alignments = sepParts.map(part => {
430
+ if (part.startsWith(':') && part.endsWith(':'))
431
+ return 'center';
432
+ if (part.endsWith(':'))
433
+ return 'right';
434
+ return 'left';
435
+ });
436
+ }
437
+ // Pad alignments to match headers
438
+ while (alignments.length < headers.length) {
439
+ alignments.push('left');
440
+ }
441
+ // Parse data rows
442
+ const data = [];
443
+ for (let i = 2; i < tableLines.length; i++) {
444
+ let line = tableLines[i].trim();
445
+ if (!line || line.startsWith('|---') || /^[|\-:\s]+$/.test(line)) {
446
+ continue;
447
+ }
448
+ if (!line.startsWith('|')) {
449
+ line = '|' + line + '|';
450
+ }
451
+ const cells = line.split('|').slice(1, -1).map(c => c.trim());
452
+ if (cells.length > 0) {
453
+ const row = {};
454
+ for (let j = 0; j < headers.length; j++) {
455
+ const header = headers[j];
456
+ let value = cells[j] ?? '';
457
+ // Try to convert to number
458
+ if (value !== '') {
459
+ if (value.includes('.')) {
460
+ const num = parseFloat(value);
461
+ if (!isNaN(num))
462
+ value = num;
463
+ }
464
+ else {
465
+ const num = parseInt(value, 10);
466
+ if (!isNaN(num))
467
+ value = num;
468
+ }
469
+ }
470
+ row[header] = value;
471
+ }
472
+ data.push(row);
473
+ }
474
+ }
475
+ if (data.length === 0) {
476
+ return null;
477
+ }
478
+ // Build columns spec
479
+ const columns = headers.map((header, i) => ({
480
+ id: header,
481
+ title: header,
482
+ align: alignments[i] ?? 'left',
483
+ }));
484
+ return {
485
+ type: 'table',
486
+ spec: { data, columns, theme },
487
+ };
488
+ }
489
+ /**
490
+ * Parse markdown layout and return HTML
491
+ */
492
+ export function parseMarkdownToDashboard(markdown, baseTheme = 'light', _baseDir, strict = false, testMode = false) {
493
+ const lines = markdown.trim().split('\n');
494
+ const { theme, pageTitle, continuous, remainingLines } = parseFrontmatter(lines, baseTheme);
495
+ const sections = [];
496
+ let currentSection = { title: '', rows: [] };
497
+ let currentRow = [];
498
+ let inCodeBlock = false;
499
+ let codeType = '';
500
+ let codeContent = [];
501
+ let codeSizeParsed = null;
502
+ let paragraphLines = [];
503
+ let inMarkdownTable = false;
504
+ let markdownTableLines = [];
505
+ const flushParagraph = () => {
506
+ if (paragraphLines.length > 0) {
507
+ const text = paragraphLines.join(' ').trim();
508
+ if (text) {
509
+ currentRow.push({ type: 'text', spec: { content: text, theme } });
510
+ }
511
+ paragraphLines = [];
512
+ }
513
+ };
514
+ const flushMarkdownTable = () => {
515
+ if (markdownTableLines.length > 0) {
516
+ const tableItem = parseMarkdownTable(markdownTableLines, theme);
517
+ if (tableItem) {
518
+ currentRow.push(tableItem);
519
+ }
520
+ markdownTableLines = [];
521
+ }
522
+ inMarkdownTable = false;
523
+ };
524
+ const flushRow = () => {
525
+ if (currentRow.length > 0) {
526
+ currentSection.rows.push(currentRow);
527
+ currentRow = [];
528
+ }
529
+ };
530
+ let codeFile = null;
531
+ for (const line of remainingLines) {
532
+ // Handle code blocks
533
+ if (line.startsWith('```')) {
534
+ if (!inCodeBlock) {
535
+ flushParagraph();
536
+ let codeHeader = line.slice(3).trim();
537
+ // Parse size=auto or size=[cols,rows]
538
+ let codeSize = null;
539
+ if (/size\s*=\s*auto\b/.test(codeHeader)) {
540
+ codeSize = 'auto';
541
+ codeHeader = codeHeader.replace(/\s*size\s*=\s*auto\b/, '');
542
+ }
543
+ else {
544
+ const sizeMatch = codeHeader.match(/size\s*=\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]/);
545
+ if (sizeMatch) {
546
+ codeSize = [parseInt(sizeMatch[1], 10), parseInt(sizeMatch[2], 10)];
547
+ codeHeader = codeHeader.replace(/\s*size\s*=\s*\[\s*\d+\s*,\s*\d+\s*\]/, '');
548
+ }
549
+ }
550
+ // Parse file= parameter
551
+ codeFile = null;
552
+ if (codeHeader.includes(' file=')) {
553
+ const parts = codeHeader.split(' file=');
554
+ codeHeader = parts[0].trim();
555
+ codeFile = parts[1]?.trim() ?? null;
556
+ }
557
+ // Parse type (first word after removing size= and file= parameters)
558
+ codeType = codeHeader.split(' ')[0] ?? '';
559
+ codeSizeParsed = codeSize;
560
+ codeContent = [];
561
+ inCodeBlock = true;
562
+ }
563
+ else {
564
+ inCodeBlock = false;
565
+ if (codeType) {
566
+ try {
567
+ let spec = {};
568
+ let fileData = null;
569
+ // Load file data if specified
570
+ if (codeFile && _baseDir) {
571
+ const filePath = resolve(_baseDir, codeFile);
572
+ if (existsSync(filePath)) {
573
+ if (codeFile.endsWith('.csv')) {
574
+ fileData = parseCsv(filePath);
575
+ }
576
+ else if (codeFile.endsWith('.json')) {
577
+ const jsonContent = readFileSync(filePath, 'utf-8');
578
+ const parsed = JSON.parse(jsonContent);
579
+ if (Array.isArray(parsed)) {
580
+ fileData = parsed;
581
+ }
582
+ else {
583
+ spec = convertColumnarFormat(parsed);
584
+ }
585
+ }
586
+ }
587
+ else if (strict) {
588
+ throw new Error(`File not found: ${filePath}`);
589
+ }
590
+ }
591
+ // Parse inline content as options or full spec
592
+ if (codeContent.length > 0) {
593
+ const inlineSpec = JSON.parse(codeContent.join('\n'));
594
+ if (fileData !== null) {
595
+ // File provides data, inline provides options
596
+ spec = { ...inlineSpec, data: fileData };
597
+ }
598
+ else {
599
+ spec = inlineSpec;
600
+ }
601
+ }
602
+ else if (fileData !== null) {
603
+ // CSV with no inline options - auto-detect x/y from columns
604
+ const columns = fileData.length > 0 ? Object.keys(fileData[0]) : [];
605
+ spec.data = fileData;
606
+ if (columns.length >= 2) {
607
+ spec.x = columns[0];
608
+ spec.y = columns[1];
609
+ }
610
+ }
611
+ // Only add item if we have data or a valid spec
612
+ if (spec.data || Object.keys(spec).length > 0) {
613
+ spec = convertColumnarFormat(spec);
614
+ spec.theme = (spec.theme ?? theme);
615
+ const item = { type: codeType, spec };
616
+ if (codeSizeParsed) {
617
+ item.size = codeSizeParsed;
618
+ }
619
+ currentRow.push(item);
620
+ }
621
+ }
622
+ catch (e) {
623
+ if (strict)
624
+ throw new Error(`Error in ${codeType} block: ${e instanceof Error ? e.message : 'Unknown error'}`);
625
+ }
626
+ }
627
+ codeType = '';
628
+ codeContent = [];
629
+ codeSizeParsed = null;
630
+ codeFile = null;
631
+ }
632
+ continue;
633
+ }
634
+ if (inCodeBlock) {
635
+ codeContent.push(line);
636
+ continue;
637
+ }
638
+ // Handle headings
639
+ // H1: Major section with page break (unless continuous mode)
640
+ if (line.startsWith('# ') && !line.startsWith('## ') && !line.startsWith('### ')) {
641
+ flushParagraph();
642
+ flushRow();
643
+ if (currentSection.rows.length > 0 || currentSection.title) {
644
+ sections.push(currentSection);
645
+ }
646
+ const sectionTitle = line.slice(2).trim();
647
+ // H1 creates a page break (will be suppressed if continuous mode is enabled)
648
+ currentSection = { title: sectionTitle, rows: [], pageBreak: !continuous };
649
+ continue;
650
+ }
651
+ // H2: Section title without page break
652
+ if (line.startsWith('## ') && !line.startsWith('### ')) {
653
+ flushParagraph();
654
+ flushRow();
655
+ if (currentSection.rows.length > 0 || currentSection.title) {
656
+ sections.push(currentSection);
657
+ }
658
+ currentSection = { title: line.slice(3).trim(), rows: [] };
659
+ continue;
660
+ }
661
+ // H3: Light inline header (subtle, smaller text within current section)
662
+ if (line.startsWith('### ')) {
663
+ flushParagraph();
664
+ if (inMarkdownTable) {
665
+ flushMarkdownTable();
666
+ }
667
+ flushRow();
668
+ const headerText = line.slice(4).trim();
669
+ // Add as an inline header item in the current row
670
+ currentRow.push({
671
+ type: 'inline_header',
672
+ spec: { text: headerText, theme },
673
+ });
674
+ flushRow();
675
+ continue;
676
+ }
677
+ // Handle page breaks (=== forces new page when printing)
678
+ if (line.trim() === '===') {
679
+ flushParagraph();
680
+ if (inMarkdownTable) {
681
+ flushMarkdownTable();
682
+ }
683
+ flushRow();
684
+ // Save current section and start a new one with page break flag
685
+ if (currentSection.rows.length > 0 || currentSection.title) {
686
+ sections.push(currentSection);
687
+ }
688
+ currentSection = { title: '', rows: [], pageBreak: true };
689
+ continue;
690
+ }
691
+ // Handle horizontal rules (section break - visual divider only)
692
+ if (['---', '***', '___'].includes(line.trim())) {
693
+ flushParagraph();
694
+ if (inMarkdownTable) {
695
+ flushMarkdownTable();
696
+ }
697
+ flushRow();
698
+ // Save current section and start a new one (without title)
699
+ if (currentSection.rows.length > 0 || currentSection.title) {
700
+ sections.push(currentSection);
701
+ }
702
+ currentSection = { title: '', rows: [] };
703
+ continue;
704
+ }
705
+ // Empty line = new row
706
+ if (!line.trim()) {
707
+ flushParagraph();
708
+ if (inMarkdownTable) {
709
+ flushMarkdownTable();
710
+ }
711
+ if (currentRow.length > 0) {
712
+ flushRow();
713
+ }
714
+ continue;
715
+ }
716
+ // Handle markdown tables (lines starting with |)
717
+ if (line.trim().startsWith('|')) {
718
+ flushParagraph();
719
+ inMarkdownTable = true;
720
+ markdownTableLines.push(line);
721
+ continue;
722
+ }
723
+ // End markdown table on non-table line
724
+ if (inMarkdownTable) {
725
+ flushMarkdownTable();
726
+ }
727
+ // Regular text
728
+ paragraphLines.push(line);
729
+ }
730
+ // Flush remaining content
731
+ flushParagraph();
732
+ if (inMarkdownTable) {
733
+ flushMarkdownTable();
734
+ }
735
+ flushRow();
736
+ if (currentSection.rows.length > 0 || currentSection.title) {
737
+ sections.push(currentSection);
738
+ }
739
+ // Generate HTML
740
+ const componentsHtml = [];
741
+ const chartScripts = [];
742
+ let chartCounter = 0;
743
+ const seenTypes = new Set();
744
+ const testItems = [];
745
+ for (const section of sections) {
746
+ const sectionHtml = [];
747
+ if (section.title) {
748
+ sectionHtml.push(`<h2 class="section-title">${escapeHtml(section.title.toUpperCase())}</h2>`);
749
+ }
750
+ for (const row of section.rows) {
751
+ resolveAutoSizes(row);
752
+ const rowItems = [];
753
+ for (const item of row) {
754
+ const compType = item.type;
755
+ const spec = item.spec;
756
+ const size = item.size ?? DEFAULT_SIZES[compType] ?? [8, 4];
757
+ const colSpan = size[0];
758
+ const rowSpan = size[1];
759
+ chartCounter++;
760
+ const chartId = `chart_${chartCounter}`;
761
+ const itemHeight = rowSpan * ROW_HEIGHT_PX;
762
+ // Get anchor for navigation (only in test mode, and only for first occurrence)
763
+ const anchorId = testMode && !seenTypes.has(compType) ? compType : '';
764
+ if (anchorId)
765
+ seenTypes.add(compType);
766
+ // Track test items for test harness mode
767
+ if (testMode) {
768
+ const testName = spec.title || spec.label ||
769
+ `${compType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())} ${chartCounter}`;
770
+ testItems.push({
771
+ id: chartId,
772
+ name: testName,
773
+ type: compType,
774
+ spec,
775
+ section: section.title || compType,
776
+ });
777
+ }
778
+ // Check for pct_bar sparkline (renders as HTML, not ECharts)
779
+ const sparkType = spec.sparkType;
780
+ if (compType === 'sparkline' && (sparkType === 'pct' || sparkType === 'pct_bar')) {
781
+ rowItems.push(renderPctBarSparkline(spec, colSpan, anchorId));
782
+ }
783
+ // Check if it's a chart type with options builder
784
+ else {
785
+ const builder = getOptionsBuilder(compType);
786
+ if (builder) {
787
+ const { html, script } = renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, anchorId);
788
+ if (html && script) {
789
+ rowItems.push(html);
790
+ chartScripts.push(script);
791
+ }
792
+ else {
793
+ rowItems.push(`<div class="grid-item"><p>Error rendering ${compType}</p></div>`);
794
+ }
795
+ }
796
+ else if (compType === 'big_value') {
797
+ rowItems.push(renderBigValue(spec, colSpan, anchorId));
798
+ }
799
+ else if (compType === 'delta') {
800
+ rowItems.push(renderDelta(spec, colSpan, anchorId));
801
+ }
802
+ else if (compType === 'alert') {
803
+ rowItems.push(renderAlert(spec, colSpan, anchorId));
804
+ }
805
+ else if (compType === 'note') {
806
+ rowItems.push(renderNote(spec, colSpan, anchorId));
807
+ }
808
+ else if (compType === 'text') {
809
+ rowItems.push(renderText(spec, colSpan, anchorId));
810
+ }
811
+ else if (compType === 'textarea') {
812
+ rowItems.push(renderTextarea(spec, colSpan, anchorId));
813
+ }
814
+ else if (compType === 'table') {
815
+ rowItems.push(renderTable(spec, colSpan, anchorId, theme));
816
+ }
817
+ else if (compType === 'empty_space') {
818
+ rowItems.push(renderEmptySpace(spec, colSpan, anchorId));
819
+ }
820
+ else if (compType === 'inline_header') {
821
+ rowItems.push(renderInlineHeader(spec, anchorId));
822
+ }
823
+ else {
824
+ rowItems.push(`<div class="grid-item"><p>Unknown type: ${compType}</p></div>`);
825
+ }
826
+ }
827
+ }
828
+ if (rowItems.length > 0) {
829
+ sectionHtml.push(`
830
+ <div class="row">
831
+ ${rowItems.join('')}
832
+ </div>`);
833
+ }
834
+ }
835
+ if (sectionHtml.length > 0) {
836
+ const sectionClasses = section.pageBreak ? 'dashboard-section page-break' : 'dashboard-section';
837
+ componentsHtml.push(`
838
+ <section class="${sectionClasses}">
839
+ ${sectionHtml.join('')}
840
+ </section>`);
841
+ }
842
+ }
843
+ // Test mode: use test harness renderer
844
+ if (testMode) {
845
+ // Count unique types for navigation
846
+ const typesCount = new Map();
847
+ for (const item of testItems) {
848
+ typesCount.set(item.type, (typesCount.get(item.type) || 0) + 1);
849
+ }
850
+ // Generate navigation items sorted alphabetically
851
+ const navItems = [...typesCount.keys()]
852
+ .sort()
853
+ .map(t => `<a href="#${t}" class="nav-item">${t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</a>`)
854
+ .join('');
855
+ // Generate test specs JSON for export
856
+ const testSpecsObj = {};
857
+ for (const item of testItems) {
858
+ testSpecsObj[item.id] = item.spec;
859
+ }
860
+ const testSpecsJson = JSON.stringify(testSpecsObj);
861
+ // Generate JavaScript
862
+ const testHarnessJs = generateTestHarnessJs(testItems.length, testSpecsJson);
863
+ return generateTestHarnessHtml(pageTitle, navItems, testItems.length, typesCount.size, componentsHtml.join(''), chartScripts.join(''), testHarnessJs, theme);
864
+ }
865
+ return generateDashboardHtml(pageTitle, componentsHtml, chartScripts, theme, '', continuous);
866
+ }
867
+ /**
868
+ * Parse markdown layout into dashboard spec (legacy API for compatibility)
869
+ */
870
+ export function parseMarkdownLayout(markdown, _baseDir) {
871
+ const lines = markdown.trim().split('\n');
872
+ const { theme, pageTitle, continuous } = parseFrontmatter(lines, 'light');
873
+ return {
874
+ frontmatter: {
875
+ theme,
876
+ title: pageTitle,
877
+ continuous,
878
+ },
879
+ rows: [],
880
+ };
881
+ }
882
+ /**
883
+ * Parse markdown into test harness HTML with navigation and summary
884
+ */
885
+ export function parseMarkdownToTestHarness(markdown, baseTheme = 'light', baseDir, strict = false) {
886
+ return parseMarkdownToDashboard(markdown, baseTheme, baseDir, strict, true);
887
+ }
888
+ //# sourceMappingURL=parser.js.map