mviz 1.6.4 → 1.6.7

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