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.
- package/README.md +8 -8
- package/dist/charts/area.js +8 -36
- package/dist/charts/bar.js +8 -26
- package/dist/charts/bubble.js +9 -7
- package/dist/charts/combo.js +17 -39
- package/dist/charts/dumbbell.js +15 -14
- package/dist/charts/funnel.js +12 -7
- package/dist/charts/heatmap.js +8 -6
- package/dist/charts/line.js +6 -27
- package/dist/charts/scatter.js +7 -5
- package/dist/cli.js +4 -3
- package/dist/components/big_value.d.ts +23 -1
- package/dist/components/big_value.js +84 -25
- package/dist/components/delta.d.ts +24 -1
- package/dist/components/delta.js +63 -17
- package/dist/components/table-interactivity.d.ts +69 -0
- package/dist/components/table-interactivity.js +216 -0
- package/dist/components/table.d.ts +6 -1
- package/dist/components/table.js +53 -12
- package/dist/core/chart-helpers.d.ts +59 -5
- package/dist/core/chart-helpers.js +84 -5
- package/dist/core/formatting.d.ts +61 -4
- package/dist/core/formatting.js +216 -17
- package/dist/core/lint-rules/registry.d.ts +4 -2
- package/dist/core/lint-rules/registry.js +6 -1
- package/dist/core/lint-rules/rules/index.d.ts +1 -0
- package/dist/core/lint-rules/rules/index.js +1 -0
- package/dist/core/lint-rules/rules/pct-scalar-gt-one.d.ts +13 -0
- package/dist/core/lint-rules/rules/pct-scalar-gt-one.js +46 -0
- package/dist/core/lint-rules/types.d.ts +12 -0
- package/dist/core/linter.d.ts +10 -2
- package/dist/core/linter.js +60 -12
- package/dist/layout/block-loader.d.ts +31 -0
- package/dist/layout/block-loader.js +143 -0
- package/dist/layout/layout-resolver.d.ts +33 -0
- package/dist/layout/layout-resolver.js +73 -0
- package/dist/layout/markdown-parser.d.ts +34 -0
- package/dist/layout/markdown-parser.js +395 -0
- package/dist/layout/parser-types.d.ts +116 -0
- package/dist/layout/parser-types.js +11 -0
- package/dist/layout/parser.d.ts +31 -22
- package/dist/layout/parser.js +118 -1006
- package/dist/layout/renderer.d.ts +33 -0
- package/dist/layout/renderer.js +450 -0
- package/dist/types.d.ts +1 -1
- package/package.json +6 -6
- package/schema/mviz.v1.schema.json +402 -33
package/dist/layout/parser.js
CHANGED
|
@@ -1,1044 +1,156 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown dashboard parser for mviz
|
|
2
|
+
* Markdown dashboard parser for mviz — orchestrator.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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, '&')
|
|
177
|
-
.replace(/</g, '<')
|
|
178
|
-
.replace(/>/g, '>')
|
|
179
|
-
.replace(/"/g, '"')
|
|
180
|
-
.replace(/'/g, ''');
|
|
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
|
-
*
|
|
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
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
*
|
|
68
|
+
* Build the final test-harness HTML from rendered output + collected test items.
|
|
456
69
|
*/
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
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
|
-
|
|
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, '&')
|
|
988
|
-
.replace(/</g, '<')
|
|
989
|
-
.replace(/>/g, '>');
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
|
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
|
|
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
|
|
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);
|