mviz 1.7.0-pre.2 → 1.7.0-pre.3
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/dist/cli.js +7 -12
- package/dist/core/embed-postprocess.d.ts +45 -0
- package/dist/core/embed-postprocess.js +494 -0
- package/dist/layout/dispatcher.d.ts +2 -7
- package/dist/layout/dispatcher.js +5 -9
- package/dist/layout/markdown-parser.js +0 -3
- package/dist/layout/parser-types.d.ts +4 -6
- package/dist/layout/parser.d.ts +2 -2
- package/dist/layout/parser.js +15 -11
- package/dist/layout/renderer.d.ts +3 -2
- package/dist/layout/renderer.js +18 -18
- package/dist/layout/templates.d.ts +2 -2
- package/dist/layout/templates.js +33 -23
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -110,8 +110,7 @@ async function main() {
|
|
|
110
110
|
console.log(' --lint, -l Validate input without generating output');
|
|
111
111
|
console.log(' --theme, -t <file> Load custom theme from YAML file');
|
|
112
112
|
console.log(' --allow-errors Continue generating output even with lint errors');
|
|
113
|
-
console.log(' --embed Strip page chrome
|
|
114
|
-
console.log(' --fragment Emit an HTML fragment (no DOCTYPE/html/head/body); implies --embed');
|
|
113
|
+
console.log(' --embed Strip page chrome + run embed post-processes for iframe-embedded output');
|
|
115
114
|
console.log(' --help, -h Show this help message');
|
|
116
115
|
console.log('');
|
|
117
116
|
console.log('Examples:');
|
|
@@ -125,16 +124,13 @@ async function main() {
|
|
|
125
124
|
// Parse flags
|
|
126
125
|
const lintOnly = args.includes('--lint') || args.includes('-l');
|
|
127
126
|
const allowErrors = args.includes('--allow-errors');
|
|
128
|
-
const
|
|
129
|
-
// Fragment mode implies embed — no chrome inside a fragment.
|
|
130
|
-
const embedFlag = fragmentFlag || args.includes('--embed');
|
|
127
|
+
const embedFlag = args.includes('--embed');
|
|
131
128
|
const { themeFile, remainingArgs: afterTheme } = parseThemeFlag(args);
|
|
132
129
|
const { outputFile, remainingArgs } = parseOutputFlag(afterTheme);
|
|
133
130
|
const filteredArgs = remainingArgs.filter((a) => a !== '--lint' &&
|
|
134
131
|
a !== '-l' &&
|
|
135
132
|
a !== '--allow-errors' &&
|
|
136
|
-
a !== '--embed'
|
|
137
|
-
a !== '--fragment');
|
|
133
|
+
a !== '--embed');
|
|
138
134
|
// Load custom theme if specified
|
|
139
135
|
let customTheme;
|
|
140
136
|
if (themeFile) {
|
|
@@ -177,8 +173,7 @@ async function main() {
|
|
|
177
173
|
console.error(' --lint, -l Validate input without generating output');
|
|
178
174
|
console.error(' --theme, -t <file> Load custom theme from YAML file');
|
|
179
175
|
console.error(' --allow-errors Continue generating output even with lint errors');
|
|
180
|
-
console.error(' --embed Strip page chrome
|
|
181
|
-
console.error(' --fragment Emit an HTML fragment (no DOCTYPE/html/head/body); implies --embed');
|
|
176
|
+
console.error(' --embed Strip page chrome + run embed post-processes for iframe-embedded output');
|
|
182
177
|
console.error(' --help, -h Show this help message');
|
|
183
178
|
process.exit(1);
|
|
184
179
|
}
|
|
@@ -193,11 +188,11 @@ async function main() {
|
|
|
193
188
|
// JSON input - lint then generate
|
|
194
189
|
const spec = JSON.parse(trimmed);
|
|
195
190
|
lintSpec(spec, lintMode);
|
|
196
|
-
html = await generateChartAsync(spec
|
|
191
|
+
html = await generateChartAsync(spec);
|
|
197
192
|
}
|
|
198
193
|
else if (trimmed.startsWith('---') || trimmed.includes('```')) {
|
|
199
194
|
// Markdown input - parser handles linting internally (async for mermaid)
|
|
200
|
-
const result = await parseMarkdownToDashboardAsync(input, 'light', baseDir, false, false, customTheme, lintMode, embedFlag
|
|
195
|
+
const result = await parseMarkdownToDashboardAsync(input, 'light', baseDir, false, false, customTheme, lintMode, embedFlag);
|
|
201
196
|
html = result.html;
|
|
202
197
|
errors = result.errors;
|
|
203
198
|
}
|
|
@@ -205,7 +200,7 @@ async function main() {
|
|
|
205
200
|
// Try JSON anyway - lint then generate
|
|
206
201
|
const spec = JSON.parse(trimmed);
|
|
207
202
|
lintSpec(spec, lintMode);
|
|
208
|
-
html = await generateChartAsync(spec
|
|
203
|
+
html = await generateChartAsync(spec);
|
|
209
204
|
}
|
|
210
205
|
// Check for errors (unless --allow-errors is set)
|
|
211
206
|
if (errors.length > 0 && !allowErrors) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed-mode post-processing (issue #272 / #247).
|
|
3
|
+
*
|
|
4
|
+
* `--embed` produces a complete HTML document but strips the page chrome
|
|
5
|
+
* (red accent bar, title row, theme toggle) so the output can be tucked
|
|
6
|
+
* inside another product's iframe (mdw-turbo Prism tiles, dashboard cells,
|
|
7
|
+
* etc.). This module is the second half of that work: a set of
|
|
8
|
+
* post-processes that run on the assembled HTML to make the embedded
|
|
9
|
+
* output as lean and host-friendly as possible.
|
|
10
|
+
*
|
|
11
|
+
* What it does:
|
|
12
|
+
* - Lift runtime style-injection IIFEs (table init creates a `<style>`
|
|
13
|
+
* element at runtime — extract its body into the inline `<style>` so
|
|
14
|
+
* pruning sees it and there's no FOUC).
|
|
15
|
+
* - Prune unused CSS rules + minify the inline `<style>` block.
|
|
16
|
+
* - Drop the ECharts CDN script tag entirely when no `echarts.init`
|
|
17
|
+
* survives — table-only / KPI-only dashboards don't need ECharts and
|
|
18
|
+
* the cold bundle is ~1 MB. Embeds that do use charts keep the full
|
|
19
|
+
* bundle (no variant downsizing — feature parity with the standalone
|
|
20
|
+
* report matters more than bundle size for iframe-embed consumers).
|
|
21
|
+
* - Drop the `marked.min.js` CDN script tag when no `marked.parse(...)`
|
|
22
|
+
* call survives. textarea / note / text components emit inline scripts
|
|
23
|
+
* that call `marked.parse`, so the script must stay whenever any of
|
|
24
|
+
* those components is rendered.
|
|
25
|
+
* - Drop the sparkline-tooltip listener (`.spark-hit` handler) when no
|
|
26
|
+
* `.spark-hit` element survives. Tables with sparkline columns still
|
|
27
|
+
* emit these, so we can't blanket-strip in embed mode.
|
|
28
|
+
* - Minify inline `<script>` whitespace (string/regex-literal-safe).
|
|
29
|
+
* - Collapse element whitespace between top-level tags.
|
|
30
|
+
*
|
|
31
|
+
* What it deliberately does NOT do:
|
|
32
|
+
* - Strip `<!DOCTYPE>` / `<html>` / `<head>` / `<body>` wrappers. Embed
|
|
33
|
+
* mode emits a complete HTML document — embedding hosts like iframe
|
|
34
|
+
* `srcdoc` need that to render. (claude.ai's `visualize:show_widget`
|
|
35
|
+
* would require fragmentation, but the show_widget integration goes
|
|
36
|
+
* through a different path — see the skill's chat-native rendering
|
|
37
|
+
* section, not this code.)
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Apply embed-mode post-processing to an assembled HTML document.
|
|
41
|
+
*
|
|
42
|
+
* Idempotent for the most part — successive calls are safe.
|
|
43
|
+
*/
|
|
44
|
+
export declare function postProcessEmbed(html: string): string;
|
|
45
|
+
//# sourceMappingURL=embed-postprocess.d.ts.map
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed-mode post-processing (issue #272 / #247).
|
|
3
|
+
*
|
|
4
|
+
* `--embed` produces a complete HTML document but strips the page chrome
|
|
5
|
+
* (red accent bar, title row, theme toggle) so the output can be tucked
|
|
6
|
+
* inside another product's iframe (mdw-turbo Prism tiles, dashboard cells,
|
|
7
|
+
* etc.). This module is the second half of that work: a set of
|
|
8
|
+
* post-processes that run on the assembled HTML to make the embedded
|
|
9
|
+
* output as lean and host-friendly as possible.
|
|
10
|
+
*
|
|
11
|
+
* What it does:
|
|
12
|
+
* - Lift runtime style-injection IIFEs (table init creates a `<style>`
|
|
13
|
+
* element at runtime — extract its body into the inline `<style>` so
|
|
14
|
+
* pruning sees it and there's no FOUC).
|
|
15
|
+
* - Prune unused CSS rules + minify the inline `<style>` block.
|
|
16
|
+
* - Drop the ECharts CDN script tag entirely when no `echarts.init`
|
|
17
|
+
* survives — table-only / KPI-only dashboards don't need ECharts and
|
|
18
|
+
* the cold bundle is ~1 MB. Embeds that do use charts keep the full
|
|
19
|
+
* bundle (no variant downsizing — feature parity with the standalone
|
|
20
|
+
* report matters more than bundle size for iframe-embed consumers).
|
|
21
|
+
* - Drop the `marked.min.js` CDN script tag when no `marked.parse(...)`
|
|
22
|
+
* call survives. textarea / note / text components emit inline scripts
|
|
23
|
+
* that call `marked.parse`, so the script must stay whenever any of
|
|
24
|
+
* those components is rendered.
|
|
25
|
+
* - Drop the sparkline-tooltip listener (`.spark-hit` handler) when no
|
|
26
|
+
* `.spark-hit` element survives. Tables with sparkline columns still
|
|
27
|
+
* emit these, so we can't blanket-strip in embed mode.
|
|
28
|
+
* - Minify inline `<script>` whitespace (string/regex-literal-safe).
|
|
29
|
+
* - Collapse element whitespace between top-level tags.
|
|
30
|
+
*
|
|
31
|
+
* What it deliberately does NOT do:
|
|
32
|
+
* - Strip `<!DOCTYPE>` / `<html>` / `<head>` / `<body>` wrappers. Embed
|
|
33
|
+
* mode emits a complete HTML document — embedding hosts like iframe
|
|
34
|
+
* `srcdoc` need that to render. (claude.ai's `visualize:show_widget`
|
|
35
|
+
* would require fragmentation, but the show_widget integration goes
|
|
36
|
+
* through a different path — see the skill's chat-native rendering
|
|
37
|
+
* section, not this code.)
|
|
38
|
+
*/
|
|
39
|
+
import { randomBytes } from 'node:crypto';
|
|
40
|
+
/**
|
|
41
|
+
* Generate a random nonce for protect-and-restore placeholders. Used to
|
|
42
|
+
* keep the markers we substitute in (during template-literal protection,
|
|
43
|
+
* block protection, etc.) from colliding with user-authored content that
|
|
44
|
+
* might happen to contain a hand-typed `__MVIZ_PROTECT_…__` string.
|
|
45
|
+
*
|
|
46
|
+
* 16 hex chars = 64 random bits. Collision probability with arbitrary
|
|
47
|
+
* user text is vanishingly small.
|
|
48
|
+
*/
|
|
49
|
+
function randomNonce() {
|
|
50
|
+
return randomBytes(8).toString('hex');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Apply embed-mode post-processing to an assembled HTML document.
|
|
54
|
+
*
|
|
55
|
+
* Idempotent for the most part — successive calls are safe.
|
|
56
|
+
*/
|
|
57
|
+
export function postProcessEmbed(html) {
|
|
58
|
+
let out = html;
|
|
59
|
+
// (Note: the PNG export menu — `.mviz-export-menu` CSS + the export
|
|
60
|
+
// IIFE in core/export.ts — never reaches this pass. `templates.ts`
|
|
61
|
+
// omits both when embed=true, before this module runs. We rely on
|
|
62
|
+
// that gate rather than carrying a defensive strip here.)
|
|
63
|
+
// 1. Lift any runtime style-injection IIFEs the table init code uses
|
|
64
|
+
// (`var s = document.createElement('style'); s.textContent = "…"; head.appendChild(s);`).
|
|
65
|
+
// Avoids a tool-call's worth of script execution latency and saves the
|
|
66
|
+
// IIFE wrapping bytes. The extracted CSS is merged into the main
|
|
67
|
+
// <style> block so the CSS pruner (step 2) still sees it.
|
|
68
|
+
out = inlineRuntimeStyleInjection(out);
|
|
69
|
+
// 2. Prune unused CSS rules + minify whitespace. The dashboard CSS carries
|
|
70
|
+
// rules for every component type mviz supports — markdown, sparkline,
|
|
71
|
+
// table, alert, etc. A typical embed uses two or three of those, so
|
|
72
|
+
// most rules are dead weight. We scan the body HTML for the class names
|
|
73
|
+
// that actually appear, then drop CSS rules whose selectors only
|
|
74
|
+
// reference classes that aren't present.
|
|
75
|
+
out = pruneAndMinifyStyle(out);
|
|
76
|
+
// 3. Drop the ECharts CDN <script> when no `echarts.init` survives the
|
|
77
|
+
// earlier transforms. Table-only / KPI-only dashboards don't need
|
|
78
|
+
// ECharts and the bundle is ~1 MB cold — meaningful win for those
|
|
79
|
+
// cases. We deliberately do NOT pick a smaller bundle variant
|
|
80
|
+
// (`simple.min.js` / `common.min.js`) — the chart-type → bundle map
|
|
81
|
+
// is brittle, and embed consumers usually want feature parity with
|
|
82
|
+
// the standalone HTML report.
|
|
83
|
+
out = dropEchartsScriptIfUnused(out);
|
|
84
|
+
// 3b. Drop the marked CDN <script> when no `marked.parse(...)` call
|
|
85
|
+
// survives. textarea / note / text components emit inline scripts
|
|
86
|
+
// that call marked.parse, so the script tag has to stay whenever
|
|
87
|
+
// those components are present. Dashboards without them (charts +
|
|
88
|
+
// KPIs + tables only) can drop the ~50 KB CDN fetch.
|
|
89
|
+
out = dropMarkedScriptIfUnused(out);
|
|
90
|
+
// 4. Drop the sparkline tooltip JS when no `.spark-hit` elements survive.
|
|
91
|
+
// Tables with sparkline columns DO emit `.spark-hit` rectangles with
|
|
92
|
+
// `data-idx` / `data-val` and need the hover handler to fire — so we
|
|
93
|
+
// can't blanket-strip in embed mode (same pattern as marked / ECharts).
|
|
94
|
+
out = dropSparklineTooltipIfUnused(out);
|
|
95
|
+
// (Note: an earlier draft also folded dead-boolean ternaries
|
|
96
|
+
// `true ? "$" : ''` → `"$"` and collapsed `{show:false, …}` sibling
|
|
97
|
+
// config. Both were post-process regex passes that couldn't reliably
|
|
98
|
+
// distinguish mviz-emitted JS code from user-authored strings inside
|
|
99
|
+
// chart data — e.g. a bar label `literal true ? 'yes' : 'no' text`
|
|
100
|
+
// would get folded into `literal 'yes' text`. The byte win was small
|
|
101
|
+
// (~200 B/chart) and the correctness risk was real, so both passes
|
|
102
|
+
// were removed. The right home for these optimizations is the codegen
|
|
103
|
+
// sites in core/formatting.ts and friends — out of scope for embed.)
|
|
104
|
+
// 5. Minify inline scripts. mviz emits chart init code with indentation
|
|
105
|
+
// + blank lines for human readability; embed hosts don't need that,
|
|
106
|
+
// and every byte costs latency (especially when a language model is
|
|
107
|
+
// re-emitting the captured output verbatim).
|
|
108
|
+
out = minifyInlineScripts(out);
|
|
109
|
+
// 6. Collapse trivial whitespace between top-level elements (line breaks
|
|
110
|
+
// + leading indentation outside of <script>/<style> blocks).
|
|
111
|
+
out = collapseElementWhitespace(out);
|
|
112
|
+
return out.trim();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Lift any `createElement('style')` + `head.appendChild` IIFEs into the
|
|
116
|
+
* main inline <style> block. mviz emits the table sortable styles via a
|
|
117
|
+
* runtime injection IIFE; in fragment mode we can hoist them so the styles
|
|
118
|
+
* are present at parse time (no FOUC) and the IIFE bytes vanish.
|
|
119
|
+
*
|
|
120
|
+
* Pattern matched (after JS minification):
|
|
121
|
+
* (function(){var s=document.createElement('style');s.textContent="…";document.head.appendChild(s);})();
|
|
122
|
+
*/
|
|
123
|
+
function inlineRuntimeStyleInjection(html) {
|
|
124
|
+
// Find the first <style>…</style> block we can merge into. If none, bail.
|
|
125
|
+
const styleMatch = /<style>([\s\S]*?)<\/style>/i.exec(html);
|
|
126
|
+
if (!styleMatch)
|
|
127
|
+
return html;
|
|
128
|
+
const iifeRe = /\(function\(\)\{\s*var\s+\w+\s*=\s*document\.createElement\(\s*['"]style['"]\s*\)\s*;\s*\w+\.textContent\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*;\s*document\.head\.appendChild\(\s*\w+\s*\)\s*;?\s*\}\)\(\);?/g;
|
|
129
|
+
const extracted = [];
|
|
130
|
+
const stripped = html.replace(iifeRe, (_m, literal) => {
|
|
131
|
+
// Unescape the JS string literal into raw CSS text.
|
|
132
|
+
const css = jsLiteralToString(literal);
|
|
133
|
+
extracted.push(css);
|
|
134
|
+
return '';
|
|
135
|
+
});
|
|
136
|
+
if (extracted.length === 0)
|
|
137
|
+
return html;
|
|
138
|
+
const mergedCss = styleMatch[1] + '\n' + extracted.join('\n');
|
|
139
|
+
return stripped.replace(/<style>[\s\S]*?<\/style>/i, `<style>${mergedCss}</style>`);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Convert a JS string literal (with surrounding quotes + escape sequences)
|
|
143
|
+
* back to its raw value. Handles \n, \t, \r, \\, \', \", \uXXXX, octal-free.
|
|
144
|
+
*/
|
|
145
|
+
function jsLiteralToString(literal) {
|
|
146
|
+
// Strip the surrounding quote.
|
|
147
|
+
const quote = literal[0];
|
|
148
|
+
if (quote !== '"' && quote !== "'")
|
|
149
|
+
return literal;
|
|
150
|
+
const body = literal.slice(1, -1);
|
|
151
|
+
let out = '';
|
|
152
|
+
for (let i = 0; i < body.length; i++) {
|
|
153
|
+
const c = body[i];
|
|
154
|
+
if (c !== '\\') {
|
|
155
|
+
out += c;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const next = body[i + 1] ?? '';
|
|
159
|
+
if (next === 'n') {
|
|
160
|
+
out += '\n';
|
|
161
|
+
i++;
|
|
162
|
+
}
|
|
163
|
+
else if (next === 't') {
|
|
164
|
+
out += '\t';
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
else if (next === 'r') {
|
|
168
|
+
out += '\r';
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
else if (next === '\\') {
|
|
172
|
+
out += '\\';
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
else if (next === "'") {
|
|
176
|
+
out += "'";
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
else if (next === '"') {
|
|
180
|
+
out += '"';
|
|
181
|
+
i++;
|
|
182
|
+
}
|
|
183
|
+
else if (next === 'u' && body.length >= i + 6) {
|
|
184
|
+
const code = parseInt(body.slice(i + 2, i + 6), 16);
|
|
185
|
+
if (!isNaN(code)) {
|
|
186
|
+
out += String.fromCharCode(code);
|
|
187
|
+
i += 5;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
out += next;
|
|
191
|
+
i++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
out += next;
|
|
196
|
+
i++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Drop the ECharts CDN `<script src>` tag when no `echarts.init` call
|
|
203
|
+
* survives the earlier transforms — i.e. table-only / KPI-only dashboards
|
|
204
|
+
* that don't actually need ECharts. Embeds that DO use charts keep the
|
|
205
|
+
* full `echarts.min.js` bundle (no variant downsizing — embed consumers
|
|
206
|
+
* usually want feature parity with the standalone report).
|
|
207
|
+
*/
|
|
208
|
+
function dropEchartsScriptIfUnused(html) {
|
|
209
|
+
if (/\becharts\.init\b/.test(html))
|
|
210
|
+
return html;
|
|
211
|
+
return html.replace(/<script src="[^"]*echarts[^"]*"><\/script>\s*/gi, '');
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Drop the `marked.min.js` CDN `<script>` tag when no `marked.parse(...)`
|
|
215
|
+
* caller survives. textarea / note / text components emit inline scripts
|
|
216
|
+
* that call `marked.parse`, so we cannot blanket-strip the script in embed
|
|
217
|
+
* mode — that would leave the inline scripts throwing `ReferenceError`.
|
|
218
|
+
*/
|
|
219
|
+
function dropMarkedScriptIfUnused(html) {
|
|
220
|
+
if (/\bmarked\.parse\s*\(/.test(html))
|
|
221
|
+
return html;
|
|
222
|
+
return html.replace(/<script src="[^"]*marked[^"]*"><\/script>\s*/gi, '');
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Drop the sparkline tooltip JS (`.spark-hit` `mouseenter`/`mouseleave`
|
|
226
|
+
* handlers) when no `.spark-hit` element survives in the body. Tables
|
|
227
|
+
* with sparkline columns DO emit `.spark-hit` rectangles and need this
|
|
228
|
+
* script to react to hovers — so this can't be blanket-stripped in
|
|
229
|
+
* embed mode. Same shape as the marked / ECharts script drops.
|
|
230
|
+
*/
|
|
231
|
+
function dropSparklineTooltipIfUnused(html) {
|
|
232
|
+
// The listener script itself references `spark-hit` as a querySelector
|
|
233
|
+
// argument, so a plain substring check would always match. Mask out
|
|
234
|
+
// <script> bodies first and check for `spark-hit` references in the
|
|
235
|
+
// remaining markup (table cells emit it as a class).
|
|
236
|
+
const withoutScripts = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
237
|
+
if (/\bspark-hit\b/.test(withoutScripts))
|
|
238
|
+
return html;
|
|
239
|
+
// The listener is bracketed by distinctive comment markers emitted by
|
|
240
|
+
// `generateDashboardHtml` (see templates.ts). Markers avoid the problem
|
|
241
|
+
// of matching nested `});` inside the arrow-function body with a
|
|
242
|
+
// non-balanced regex.
|
|
243
|
+
return html.replace(/\/\/\s*---\s*mviz sparkline tooltip start\s*---[\s\S]*?\/\/\s*---\s*mviz sparkline tooltip end\s*---\s*/g, '');
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Prune unused CSS rules from the inline `<style>` block, then minify the
|
|
247
|
+
* remaining CSS by collapsing whitespace.
|
|
248
|
+
*
|
|
249
|
+
* Heuristic — for each top-level rule (including those inside `@media`):
|
|
250
|
+
* - Extract the class names referenced in the selector list.
|
|
251
|
+
* - If at least one is present in the body HTML (or in a known
|
|
252
|
+
* always-keep list like `:root`, `html`, `body`, `*`, `@media`), keep
|
|
253
|
+
* the rule. Otherwise drop it.
|
|
254
|
+
*
|
|
255
|
+
* Then collapse runs of whitespace to single spaces, and strip whitespace
|
|
256
|
+
* adjacent to CSS punctuation (`{`, `}`, `;`, `:`, `,`).
|
|
257
|
+
*/
|
|
258
|
+
function pruneAndMinifyStyle(html) {
|
|
259
|
+
const styleRe = /<style>([\s\S]*?)<\/style>/i;
|
|
260
|
+
const m = styleRe.exec(html);
|
|
261
|
+
if (!m)
|
|
262
|
+
return html;
|
|
263
|
+
const css = m[1] ?? '';
|
|
264
|
+
// Body content for class-presence checks: everything between </style> and
|
|
265
|
+
// the trailing scripts. We include the whole post-style remainder so chart
|
|
266
|
+
// `<div id="…">` class hints on grid-item rows are caught.
|
|
267
|
+
const body = html.slice(m.index + m[0].length);
|
|
268
|
+
const classesInBody = collectClassNames(body);
|
|
269
|
+
const pruned = pruneCssRules(css, classesInBody);
|
|
270
|
+
const minified = minifyCss(pruned);
|
|
271
|
+
return html.slice(0, m.index) + `<style>${minified}</style>` + body;
|
|
272
|
+
}
|
|
273
|
+
/** Collect all `class="…"` tokens from an HTML body. */
|
|
274
|
+
function collectClassNames(body) {
|
|
275
|
+
const out = new Set();
|
|
276
|
+
const classAttrRe = /\bclass\s*=\s*"([^"]*)"/g;
|
|
277
|
+
let cm;
|
|
278
|
+
while ((cm = classAttrRe.exec(body)) !== null) {
|
|
279
|
+
for (const cls of (cm[1] ?? '').split(/\s+/)) {
|
|
280
|
+
if (cls)
|
|
281
|
+
out.add(cls);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
/** Always-keep selector prefixes/keywords that have no class to test. */
|
|
287
|
+
const KEEP_PREFIXES = [':root', '*', 'html', 'body', '@media', '@keyframes', '@font-face', '@supports', '@import'];
|
|
288
|
+
function pruneCssRules(css, classes) {
|
|
289
|
+
// Split CSS into top-level rules: a selector list up to a `{`, then a
|
|
290
|
+
// balanced body up to the matching `}`. Handle @media by recursively
|
|
291
|
+
// pruning its inner rules with the same class set.
|
|
292
|
+
const out = [];
|
|
293
|
+
let i = 0;
|
|
294
|
+
while (i < css.length) {
|
|
295
|
+
// Skip whitespace.
|
|
296
|
+
while (i < css.length && /\s/.test(css[i] ?? ''))
|
|
297
|
+
i++;
|
|
298
|
+
if (i >= css.length)
|
|
299
|
+
break;
|
|
300
|
+
// Comment.
|
|
301
|
+
if (css.startsWith('/*', i)) {
|
|
302
|
+
const end = css.indexOf('*/', i + 2);
|
|
303
|
+
i = end === -1 ? css.length : end + 2;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
// Find the next `{` (selector end) or `;` (at-rule like @import).
|
|
307
|
+
const braceIdx = css.indexOf('{', i);
|
|
308
|
+
const semiIdx = css.indexOf(';', i);
|
|
309
|
+
if (braceIdx === -1 || (semiIdx !== -1 && semiIdx < braceIdx)) {
|
|
310
|
+
// Standalone at-rule like `@import url(…);` — keep it.
|
|
311
|
+
const ruleEnd = semiIdx === -1 ? css.length : semiIdx + 1;
|
|
312
|
+
out.push(css.slice(i, ruleEnd));
|
|
313
|
+
i = ruleEnd;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const selector = css.slice(i, braceIdx).trim();
|
|
317
|
+
// Find balanced closing brace.
|
|
318
|
+
let depth = 1;
|
|
319
|
+
let j = braceIdx + 1;
|
|
320
|
+
while (j < css.length && depth > 0) {
|
|
321
|
+
const ch = css[j];
|
|
322
|
+
if (ch === '{')
|
|
323
|
+
depth++;
|
|
324
|
+
else if (ch === '}')
|
|
325
|
+
depth--;
|
|
326
|
+
if (depth > 0)
|
|
327
|
+
j++;
|
|
328
|
+
}
|
|
329
|
+
const body = css.slice(braceIdx + 1, j);
|
|
330
|
+
i = j + 1;
|
|
331
|
+
if (selector.startsWith('@media') || selector.startsWith('@supports')) {
|
|
332
|
+
// Recurse: prune inner rules with the same class set.
|
|
333
|
+
const innerPruned = pruneCssRules(body, classes).trim();
|
|
334
|
+
if (innerPruned)
|
|
335
|
+
out.push(`${selector} { ${innerPruned} }`);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (selector.startsWith('@')) {
|
|
339
|
+
// Other at-rules (@keyframes, @font-face): keep as-is.
|
|
340
|
+
out.push(`${selector} {${body}}`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (selectorMatchesAnyClass(selector, classes)) {
|
|
344
|
+
out.push(`${selector} {${body}}`);
|
|
345
|
+
}
|
|
346
|
+
// else: drop the rule.
|
|
347
|
+
}
|
|
348
|
+
return out.join('\n');
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Decide whether a selector list should be kept given the set of classes
|
|
352
|
+
* actually present in the body. Selectors with no class reference (e.g. `*`,
|
|
353
|
+
* `html, body`, `:root`) are always kept. A selector list is kept if at
|
|
354
|
+
* least one comma-separated selector references a present class, or if any
|
|
355
|
+
* selector has no class reference at all (it targets tags / ids / globals).
|
|
356
|
+
*/
|
|
357
|
+
function selectorMatchesAnyClass(selectorList, classes) {
|
|
358
|
+
const selectors = selectorList.split(',').map((s) => s.trim());
|
|
359
|
+
for (const sel of selectors) {
|
|
360
|
+
if (KEEP_PREFIXES.some((p) => sel.startsWith(p)))
|
|
361
|
+
return true;
|
|
362
|
+
const classRefs = [...sel.matchAll(/\.([A-Za-z_][\w-]*)/g)].map((m) => m[1] ?? '');
|
|
363
|
+
if (classRefs.length === 0) {
|
|
364
|
+
// No class reference — targets a tag or id selector. Keep.
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
// Keep only if every required class in this selector is present.
|
|
368
|
+
if (classRefs.every((c) => classes.has(c)))
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
function minifyCss(css) {
|
|
374
|
+
return css
|
|
375
|
+
.replace(/\/\*[\s\S]*?\*\//g, '') // comments
|
|
376
|
+
.replace(/\s+/g, ' ') // collapse whitespace
|
|
377
|
+
.replace(/\s*([{}:;,>])\s*/g, '$1') // strip spaces around punctuation
|
|
378
|
+
.replace(/;}/g, '}') // drop trailing semicolons
|
|
379
|
+
.trim();
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Apply conservative whitespace minification to every `<script>` block
|
|
383
|
+
* (skipping `<script src=…>` which has no body). String literals and regex
|
|
384
|
+
* literals are preserved verbatim; everything else is folded into a single
|
|
385
|
+
* stream with line comments stripped and run-of-whitespace collapsed.
|
|
386
|
+
*/
|
|
387
|
+
function minifyInlineScripts(html) {
|
|
388
|
+
return html.replace(/<script>([\s\S]*?)<\/script>/gi, (_match, body) => {
|
|
389
|
+
return `<script>${minifyJs(body)}</script>`;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Conservative JS whitespace minifier. Walks the source character-by-character
|
|
394
|
+
* so it can leave string and regex literals untouched. Strips:
|
|
395
|
+
* - `// line comments` (to end of line)
|
|
396
|
+
* - `/* block comments * /`
|
|
397
|
+
* - leading/trailing whitespace
|
|
398
|
+
* - runs of whitespace (collapsed to single space, or removed entirely when
|
|
399
|
+
* adjacent to non-identifier punctuation)
|
|
400
|
+
*
|
|
401
|
+
* Does NOT do symbol renaming or dead-code elimination — purely whitespace.
|
|
402
|
+
*/
|
|
403
|
+
function minifyJs(src) {
|
|
404
|
+
const out = [];
|
|
405
|
+
const n = src.length;
|
|
406
|
+
let i = 0;
|
|
407
|
+
const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
|
|
408
|
+
const isIdent = (c) => /[A-Za-z0-9_$]/.test(c);
|
|
409
|
+
while (i < n) {
|
|
410
|
+
const c = src[i] ?? '';
|
|
411
|
+
const c1 = src[i + 1] ?? '';
|
|
412
|
+
// Line comment.
|
|
413
|
+
if (c === '/' && c1 === '/') {
|
|
414
|
+
while (i < n && src[i] !== '\n')
|
|
415
|
+
i++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// Block comment.
|
|
419
|
+
if (c === '/' && c1 === '*') {
|
|
420
|
+
const end = src.indexOf('*/', i + 2);
|
|
421
|
+
i = end === -1 ? n : end + 2;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// String literal — copy verbatim, honoring escapes.
|
|
425
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
426
|
+
const quote = c;
|
|
427
|
+
out.push(quote);
|
|
428
|
+
i++;
|
|
429
|
+
while (i < n) {
|
|
430
|
+
const ch = src[i] ?? '';
|
|
431
|
+
out.push(ch);
|
|
432
|
+
if (ch === '\\') {
|
|
433
|
+
// Copy the escaped char (if any) and continue.
|
|
434
|
+
if (i + 1 < n) {
|
|
435
|
+
out.push(src[i + 1] ?? '');
|
|
436
|
+
i += 2;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
i++;
|
|
441
|
+
if (ch === quote)
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
// Whitespace — collapse to a single space, but drop entirely when both
|
|
447
|
+
// neighbours are non-identifier punctuation (`,`, `;`, `{`, `}`, etc.).
|
|
448
|
+
if (isWs(c)) {
|
|
449
|
+
let j = i;
|
|
450
|
+
while (j < n && isWs(src[j] ?? ''))
|
|
451
|
+
j++;
|
|
452
|
+
const prev = out.length > 0 ? (out[out.length - 1] ?? '') : '';
|
|
453
|
+
const next = src[j] ?? '';
|
|
454
|
+
if (prev && next && (isIdent(prev) || prev === ')' || prev === ']') && (isIdent(next) || next === '/')) {
|
|
455
|
+
// Need a separator to avoid token merging (e.g. `var x` → `varx`).
|
|
456
|
+
out.push(' ');
|
|
457
|
+
}
|
|
458
|
+
i = j;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
out.push(c);
|
|
462
|
+
i++;
|
|
463
|
+
}
|
|
464
|
+
return out.join('').trim();
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Collapse the line breaks + leading indentation that mviz uses between
|
|
468
|
+
* top-level elements in its templates. Conservative — only strips runs of
|
|
469
|
+
* `\n` + spaces that sit *between* tags. Leaves whitespace inside `<script>`,
|
|
470
|
+
* `<style>`, `<pre>`, and `<textarea>` alone.
|
|
471
|
+
*/
|
|
472
|
+
function collapseElementWhitespace(html) {
|
|
473
|
+
// Protect <pre>, <textarea>, <script>, <style> blocks so we don't touch
|
|
474
|
+
// their contents. Sub them out, collapse, then put back. The placeholder
|
|
475
|
+
// uses a printable marker keyed by a per-call random nonce — so user
|
|
476
|
+
// content that happens to contain a literal `__MVIZ_PROTECT_…__` string
|
|
477
|
+
// can't accidentally get replaced by the restore pass. (The plain-text
|
|
478
|
+
// form also keeps the source file from being misclassified as binary by
|
|
479
|
+
// tools like rg/file — an earlier draft used literal NUL bytes.)
|
|
480
|
+
const protectedBlocks = [];
|
|
481
|
+
const nonce = randomNonce();
|
|
482
|
+
const protectRe = /<(script|style|pre|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
|
483
|
+
const placeholder = (idx) => `__MVIZ_PROTECT_${nonce}_${idx}__`;
|
|
484
|
+
const masked = html.replace(protectRe, (m) => {
|
|
485
|
+
protectedBlocks.push(m);
|
|
486
|
+
return placeholder(protectedBlocks.length - 1);
|
|
487
|
+
});
|
|
488
|
+
const collapsed = masked.replace(/>\s+</g, '><').trim();
|
|
489
|
+
const restoreRe = new RegExp(`__MVIZ_PROTECT_${nonce}_(\\d+)__`, 'g');
|
|
490
|
+
return collapsed.replace(restoreRe, (_m, n) => {
|
|
491
|
+
return protectedBlocks[parseInt(n, 10)] ?? '';
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
//# sourceMappingURL=embed-postprocess.js.map
|
|
@@ -4,11 +4,6 @@
|
|
|
4
4
|
* Routes chart/component specs to the appropriate generator.
|
|
5
5
|
*/
|
|
6
6
|
import type { ChartSpec, ComponentSpec } from '../types.js';
|
|
7
|
-
export interface GenerateOptions {
|
|
8
|
-
/** Emit a self-contained HTML fragment (no DOCTYPE/html/head/body) suitable
|
|
9
|
-
* for embedding inside hosts like claude.ai's `visualize:show_widget`. */
|
|
10
|
-
fragment?: boolean;
|
|
11
|
-
}
|
|
12
7
|
import '../charts/index.js';
|
|
13
8
|
import '../components/index.js';
|
|
14
9
|
/**
|
|
@@ -21,11 +16,11 @@ export declare function isAsyncChartType(type: string): boolean;
|
|
|
21
16
|
* Note: This will throw an error for async chart types (like mermaid).
|
|
22
17
|
* Use generateChartAsync for those.
|
|
23
18
|
*/
|
|
24
|
-
export declare function generateChart(spec: ChartSpec | ComponentSpec
|
|
19
|
+
export declare function generateChart(spec: ChartSpec | ComponentSpec): string;
|
|
25
20
|
/**
|
|
26
21
|
* Generate HTML output for a chart or component spec (async version)
|
|
27
22
|
*
|
|
28
23
|
* This handles both sync and async chart types.
|
|
29
24
|
*/
|
|
30
|
-
export declare function generateChartAsync(spec: ChartSpec | ComponentSpec
|
|
25
|
+
export declare function generateChartAsync(spec: ChartSpec | ComponentSpec): Promise<string>;
|
|
31
26
|
//# sourceMappingURL=dispatcher.d.ts.map
|
|
@@ -7,7 +7,6 @@ import { CHART_REGISTRY, ASYNC_CHART_REGISTRY } from '../charts/registry.js';
|
|
|
7
7
|
import { COMPONENT_REGISTRY } from '../components/registry.js';
|
|
8
8
|
import { convertColumnarFormat } from './converter.js';
|
|
9
9
|
import { UnknownComponentType } from '../core/exceptions.js';
|
|
10
|
-
import { toFragment } from '../core/fragment.js';
|
|
11
10
|
// Import chart and component modules to trigger registration
|
|
12
11
|
import '../charts/index.js';
|
|
13
12
|
import '../components/index.js';
|
|
@@ -37,7 +36,7 @@ export function isAsyncChartType(type) {
|
|
|
37
36
|
* Note: This will throw an error for async chart types (like mermaid).
|
|
38
37
|
* Use generateChartAsync for those.
|
|
39
38
|
*/
|
|
40
|
-
export function generateChart(spec
|
|
39
|
+
export function generateChart(spec) {
|
|
41
40
|
// Convert columnar format if needed
|
|
42
41
|
const converted = convertColumnarFormat(spec);
|
|
43
42
|
const type = converted.type;
|
|
@@ -45,7 +44,7 @@ export function generateChart(spec, options = {}) {
|
|
|
45
44
|
if (ASYNC_CHART_REGISTRY.has(type)) {
|
|
46
45
|
throw new Error(`Chart type '${type}' requires async rendering. Use generateChartAsync instead.`);
|
|
47
46
|
}
|
|
48
|
-
return
|
|
47
|
+
return dispatchSync(type, converted);
|
|
49
48
|
}
|
|
50
49
|
function dispatchSync(type, converted) {
|
|
51
50
|
// Try chart registry first
|
|
@@ -62,24 +61,21 @@ function dispatchSync(type, converted) {
|
|
|
62
61
|
const suggestions = getSimilarTypes(type);
|
|
63
62
|
throw new UnknownComponentType(type, suggestions);
|
|
64
63
|
}
|
|
65
|
-
function finalize(html, options) {
|
|
66
|
-
return options.fragment ? toFragment(html) : html;
|
|
67
|
-
}
|
|
68
64
|
/**
|
|
69
65
|
* Generate HTML output for a chart or component spec (async version)
|
|
70
66
|
*
|
|
71
67
|
* This handles both sync and async chart types.
|
|
72
68
|
*/
|
|
73
|
-
export async function generateChartAsync(spec
|
|
69
|
+
export async function generateChartAsync(spec) {
|
|
74
70
|
// Convert columnar format if needed
|
|
75
71
|
const converted = convertColumnarFormat(spec);
|
|
76
72
|
const type = converted.type;
|
|
77
73
|
// Try async chart registry first
|
|
78
74
|
const asyncChartGenerator = ASYNC_CHART_REGISTRY.get(type);
|
|
79
75
|
if (asyncChartGenerator) {
|
|
80
|
-
return
|
|
76
|
+
return asyncChartGenerator(converted);
|
|
81
77
|
}
|
|
82
78
|
// Fall back to sync paths
|
|
83
|
-
return
|
|
79
|
+
return dispatchSync(type, converted);
|
|
84
80
|
}
|
|
85
81
|
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -35,7 +35,6 @@ function createDefaultFrontmatter(lines, baseTheme) {
|
|
|
35
35
|
orientation: 'portrait',
|
|
36
36
|
printMode: false,
|
|
37
37
|
embed: false,
|
|
38
|
-
fragment: false,
|
|
39
38
|
currency: undefined,
|
|
40
39
|
remainingLines: lines,
|
|
41
40
|
frontmatterEndLine: 0,
|
|
@@ -72,8 +71,6 @@ function applyFrontmatterLine(target, line) {
|
|
|
72
71
|
target.printMode = isTruthy(val);
|
|
73
72
|
else if (key === 'embed')
|
|
74
73
|
target.embed = isTruthy(val);
|
|
75
|
-
else if (key === 'fragment')
|
|
76
|
-
target.fragment = isTruthy(val);
|
|
77
74
|
else if (key === 'currency')
|
|
78
75
|
target.currency = val.toUpperCase();
|
|
79
76
|
}
|
|
@@ -18,13 +18,11 @@ export interface ParsedFrontmatter {
|
|
|
18
18
|
continuous: boolean;
|
|
19
19
|
orientation: PageOrientation;
|
|
20
20
|
printMode: boolean;
|
|
21
|
-
/** When true, omit page chrome (red bar, title row, theme toggle)
|
|
22
|
-
*
|
|
21
|
+
/** When true, omit page chrome (red bar, title row, theme toggle) and
|
|
22
|
+
* run the embed post-processes (CSS prune, ECharts right-size, JS
|
|
23
|
+
* minify, transparent backgrounds, etc.) so the output can be embedded
|
|
24
|
+
* inside another host's frame (e.g. iframe tiles, dashboard cells). */
|
|
23
25
|
embed: boolean;
|
|
24
|
-
/** When true, emit a self-contained HTML fragment with no
|
|
25
|
-
* `<!DOCTYPE>`/`<html>`/`<head>`/`<body>` wrappers. Implies `embed`.
|
|
26
|
-
* Designed for hosts like claude.ai's `visualize:show_widget`. */
|
|
27
|
-
fragment: boolean;
|
|
28
26
|
currency: string | undefined;
|
|
29
27
|
/** Lines of the markdown body after the closing `---`. */
|
|
30
28
|
remainingLines: string[];
|
package/dist/layout/parser.d.ts
CHANGED
|
@@ -25,11 +25,11 @@ export type { MermaidBlock, ParseResult } from './parser-types.js';
|
|
|
25
25
|
* Synchronous variant — mermaid blocks are emitted as placeholders and must be
|
|
26
26
|
* resolved by `parseMarkdownToDashboardAsync` for final rendering.
|
|
27
27
|
*/
|
|
28
|
-
export declare function parseMarkdownToDashboard(markdown: string, baseTheme?: string, baseDir?: string, strict?: boolean, testMode?: boolean, customTheme?: CustomTheme, lintMode?: 'generate' | 'lint', embedOverride?: boolean
|
|
28
|
+
export declare function parseMarkdownToDashboard(markdown: string, baseTheme?: string, baseDir?: string, strict?: boolean, testMode?: boolean, customTheme?: CustomTheme, lintMode?: 'generate' | 'lint', embedOverride?: boolean): ParseResult;
|
|
29
29
|
/**
|
|
30
30
|
* Async variant that resolves mermaid placeholders to inline SVG.
|
|
31
31
|
*/
|
|
32
|
-
export declare function parseMarkdownToDashboardAsync(markdown: string, baseTheme?: string, baseDir?: string, strict?: boolean, testMode?: boolean, customTheme?: CustomTheme, lintMode?: 'generate' | 'lint', embedOverride?: boolean
|
|
32
|
+
export declare function parseMarkdownToDashboardAsync(markdown: string, baseTheme?: string, baseDir?: string, strict?: boolean, testMode?: boolean, customTheme?: CustomTheme, lintMode?: 'generate' | 'lint', embedOverride?: boolean): Promise<{
|
|
33
33
|
html: string;
|
|
34
34
|
errors: string[];
|
|
35
35
|
}>;
|
package/dist/layout/parser.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { renderMermaid, renderMermaidAscii } from 'beautiful-mermaid';
|
|
20
20
|
import { getThemeColors } from '../core/themes.js';
|
|
21
|
-
import {
|
|
21
|
+
import { postProcessEmbed } from '../core/embed-postprocess.js';
|
|
22
22
|
import { generateDashboardHtml, generateTestHarnessHtml, generateTestHarnessJs } from './templates.js';
|
|
23
23
|
import { parseFrontmatter, parseMarkdown } from './markdown-parser.js';
|
|
24
24
|
import { loadSections } from './block-loader.js';
|
|
@@ -30,7 +30,7 @@ import { renderSections } from './renderer.js';
|
|
|
30
30
|
* Synchronous variant — mermaid blocks are emitted as placeholders and must be
|
|
31
31
|
* resolved by `parseMarkdownToDashboardAsync` for final rendering.
|
|
32
32
|
*/
|
|
33
|
-
export function parseMarkdownToDashboard(markdown, baseTheme = 'light', baseDir, strict = false, testMode = false, customTheme, lintMode = 'generate', embedOverride = false
|
|
33
|
+
export function parseMarkdownToDashboard(markdown, baseTheme = 'light', baseDir, strict = false, testMode = false, customTheme, lintMode = 'generate', embedOverride = false) {
|
|
34
34
|
// 1. Parse: markdown -> raw IR
|
|
35
35
|
const parsed = parseMarkdown(markdown, baseTheme);
|
|
36
36
|
const { frontmatter, sections: rawSections, pageTitle } = parsed;
|
|
@@ -50,24 +50,28 @@ export function parseMarkdownToDashboard(markdown, baseTheme = 'light', baseDir,
|
|
|
50
50
|
});
|
|
51
51
|
errors.push(...layoutResult.errors);
|
|
52
52
|
// 4. Render: IR -> componentsHtml + scripts (+ collect test items, mermaid blocks)
|
|
53
|
-
|
|
53
|
+
// Embed flag is ignored in testMode — the visual test harness has its
|
|
54
|
+
// own theme toggle which relies on full chart options (window.chartInstances
|
|
55
|
+
// / window.chartOptions populated). Embed-mode chart scripts skip those,
|
|
56
|
+
// so honoring `embed: true` here would silently break the harness toggle.
|
|
57
|
+
const embedMode = (frontmatter.embed || embedOverride) && !testMode;
|
|
54
58
|
const renderOpts = {
|
|
55
59
|
theme: frontmatter.theme,
|
|
56
60
|
testMode,
|
|
57
|
-
|
|
61
|
+
embed: embedMode,
|
|
58
62
|
};
|
|
59
63
|
if (customTheme)
|
|
60
64
|
renderOpts.customTheme = customTheme;
|
|
61
65
|
if (frontmatter.currency)
|
|
62
66
|
renderOpts.currency = frontmatter.currency;
|
|
63
67
|
const rendered = renderSections(loaded.sections, renderOpts);
|
|
64
|
-
// 5. Assemble final page.
|
|
65
|
-
//
|
|
66
|
-
|
|
68
|
+
// 5. Assemble final page. Embed mode drops the page chrome (red bar,
|
|
69
|
+
// title row, theme toggle) and runs a battery of post-processes that
|
|
70
|
+
// shrink + lean the body for iframe-embedded contexts.
|
|
67
71
|
const fullDoc = testMode
|
|
68
72
|
? assembleTestHarness(pageTitle, frontmatter.theme, rendered.componentsHtml, rendered.chartScripts, rendered.testItems)
|
|
69
|
-
: generateDashboardHtml(pageTitle, [rendered.componentsHtml], [rendered.chartScripts], frontmatter.theme, '', frontmatter.continuous, customTheme, frontmatter.orientation, embedMode
|
|
70
|
-
const html =
|
|
73
|
+
: generateDashboardHtml(pageTitle, [rendered.componentsHtml], [rendered.chartScripts], frontmatter.theme, '', frontmatter.continuous, customTheme, frontmatter.orientation, embedMode);
|
|
74
|
+
const html = embedMode ? postProcessEmbed(fullDoc) : fullDoc;
|
|
71
75
|
return { html, errors, mermaidBlocks: rendered.mermaidBlocks };
|
|
72
76
|
}
|
|
73
77
|
/**
|
|
@@ -96,8 +100,8 @@ function buildNav(typesCount) {
|
|
|
96
100
|
/**
|
|
97
101
|
* Async variant that resolves mermaid placeholders to inline SVG.
|
|
98
102
|
*/
|
|
99
|
-
export async function parseMarkdownToDashboardAsync(markdown, baseTheme = 'light', baseDir, strict = false, testMode = false, customTheme, lintMode = 'generate', embedOverride = false
|
|
100
|
-
const result = parseMarkdownToDashboard(markdown, baseTheme, baseDir, strict, testMode, customTheme, lintMode, embedOverride
|
|
103
|
+
export async function parseMarkdownToDashboardAsync(markdown, baseTheme = 'light', baseDir, strict = false, testMode = false, customTheme, lintMode = 'generate', embedOverride = false) {
|
|
104
|
+
const result = parseMarkdownToDashboard(markdown, baseTheme, baseDir, strict, testMode, customTheme, lintMode, embedOverride);
|
|
101
105
|
if (result.mermaidBlocks.length === 0) {
|
|
102
106
|
return { html: result.html, errors: result.errors };
|
|
103
107
|
}
|
|
@@ -18,8 +18,9 @@ export interface RenderOptions {
|
|
|
18
18
|
customTheme?: CustomTheme;
|
|
19
19
|
currency?: string;
|
|
20
20
|
/** When true, emit slimmer chart scripts (no light/dark options dict, no
|
|
21
|
-
* body-class theme check) for
|
|
22
|
-
|
|
21
|
+
* body-class theme check) for embed output. Embed widgets are one-shot
|
|
22
|
+
* snapshots — no theme toggle to react to. */
|
|
23
|
+
embed?: boolean;
|
|
23
24
|
}
|
|
24
25
|
export interface RenderResult {
|
|
25
26
|
componentsHtml: string;
|
package/dist/layout/renderer.js
CHANGED
|
@@ -111,7 +111,7 @@ function renderByType(compType, spec, args, ctx) {
|
|
|
111
111
|
return renderSimpleComponent(compType, spec, args, ctx);
|
|
112
112
|
}
|
|
113
113
|
function renderEchartsAndCollect(compType, spec, args, ctx) {
|
|
114
|
-
const { html, script } = renderEchartsComponent(compType, spec, args.chartId, args.colSpan, args.itemHeight, args.anchorId, ctx.options.customTheme, ctx.options.
|
|
114
|
+
const { html, script } = renderEchartsComponent(compType, spec, args.chartId, args.colSpan, args.itemHeight, args.anchorId, ctx.options.customTheme, ctx.options.embed ?? false, ctx.options.theme === 'dark' ? 'dark' : 'light');
|
|
115
115
|
if (!html)
|
|
116
116
|
return `<div class="grid-item"><p>Error rendering ${compType}</p></div>`;
|
|
117
117
|
if (script)
|
|
@@ -167,7 +167,7 @@ function trackTestItem(ctx, chartId, compType, spec, section) {
|
|
|
167
167
|
// =============================================================================
|
|
168
168
|
// ECharts component rendering
|
|
169
169
|
// =============================================================================
|
|
170
|
-
function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, anchorId, customTheme,
|
|
170
|
+
function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, anchorId, customTheme, embed = false, baseTheme = 'light') {
|
|
171
171
|
const title = spec.title ?? '';
|
|
172
172
|
const titleHtml = title ? `<h3 class="chart-title">${escapeHtml(title)}</h3>` : '';
|
|
173
173
|
const builder = getOptionsBuilder(compType);
|
|
@@ -175,43 +175,43 @@ function renderEchartsComponent(compType, spec, chartId, colSpan, itemHeight, an
|
|
|
175
175
|
return { html: null, script: null };
|
|
176
176
|
const chartHeight = itemHeight - (title ? 24 : 0);
|
|
177
177
|
const fontFamily = getFontsWithCustom(customTheme).family;
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
const optionJsonLight = buildEchartsOption(spec, baseTheme, chartHeight, customTheme, fontFamily, builder,
|
|
178
|
+
// Embed mode also drops JSON pretty-print indentation — every byte costs
|
|
179
|
+
// bandwidth + parse time inside the embedding host's iframe.
|
|
180
|
+
const optionJsonLight = buildEchartsOption(spec, baseTheme, chartHeight, customTheme, fontFamily, builder, embed);
|
|
181
181
|
if (!optionJsonLight)
|
|
182
182
|
return { html: null, script: null };
|
|
183
|
-
// Skip the dark variant entirely in
|
|
184
|
-
// snapshots, not theme-switchable surfaces. ~
|
|
185
|
-
const optionJsonDark =
|
|
183
|
+
// Skip the dark variant entirely in embed mode — embed widgets are one-shot
|
|
184
|
+
// snapshots, not theme-switchable surfaces. ~3 KB of JSON per chart saved.
|
|
185
|
+
const optionJsonDark = embed ? null : buildEchartsOption(spec, 'dark', chartHeight, customTheme, fontFamily, builder, false);
|
|
186
186
|
const anchorAttr = anchorId ? ` id="${anchorId}"` : '';
|
|
187
187
|
const html = `
|
|
188
188
|
<div class="grid-item"${anchorAttr} style="--col-span: ${colSpan}; grid-column: span ${colSpan};">
|
|
189
189
|
${titleHtml}
|
|
190
190
|
<div id="${chartId}" style="width: 100%; height: ${chartHeight}px;"></div>
|
|
191
191
|
</div>`;
|
|
192
|
-
return { html, script: generateChartScript(chartId, optionJsonLight, optionJsonDark,
|
|
192
|
+
return { html, script: generateChartScript(chartId, optionJsonLight, optionJsonDark, embed) };
|
|
193
193
|
}
|
|
194
194
|
function buildEchartsOption(spec, theme, chartHeight, customTheme, fontFamily, builder,
|
|
195
|
-
// Drop pretty-print indentation when true — used for
|
|
196
|
-
// shrink what
|
|
195
|
+
// Drop pretty-print indentation when true — used for embed outputs to
|
|
196
|
+
// shrink what the host iframe has to parse.
|
|
197
197
|
minify = false) {
|
|
198
198
|
const themedSpec = { ...spec, theme, height: chartHeight, customTheme };
|
|
199
199
|
const option = builder(themedSpec);
|
|
200
200
|
if (!option)
|
|
201
201
|
return null;
|
|
202
|
-
// Skip the per-chart fontFamily injection in
|
|
203
|
-
// from the parent container (and ECharts renders SVG in mviz). Saves
|
|
204
|
-
// bytes per chart per option object
|
|
202
|
+
// Skip the per-chart fontFamily injection in embed mode — SVG inherits
|
|
203
|
+
// from the parent container (and ECharts renders SVG in mviz). Saves
|
|
204
|
+
// ~50 bytes per chart per option object.
|
|
205
205
|
if (!minify) {
|
|
206
206
|
option.textStyle = { ...(option.textStyle ?? {}), fontFamily };
|
|
207
207
|
}
|
|
208
208
|
return serializeOption(option, { pretty: !minify });
|
|
209
209
|
}
|
|
210
|
-
function generateChartScript(chartId, optionJsonLight, optionJsonDark,
|
|
211
|
-
//
|
|
212
|
-
//
|
|
210
|
+
function generateChartScript(chartId, optionJsonLight, optionJsonDark, embed = false) {
|
|
211
|
+
// Embed mode: drop the chartOptions dict and the dark variant entirely.
|
|
212
|
+
// Embed widgets are one-shot snapshots — no theme toggle button, no
|
|
213
213
|
// host-driven theme swap to react to — so the light option is what renders.
|
|
214
|
-
if (
|
|
214
|
+
if (embed) {
|
|
215
215
|
return `
|
|
216
216
|
(function() {
|
|
217
217
|
var chart = echarts.init(document.getElementById('${chartId}'), null, {renderer: 'svg'});
|
|
@@ -9,11 +9,11 @@ export declare function escapeHtml(text: string): string;
|
|
|
9
9
|
/**
|
|
10
10
|
* Generate the dashboard CSS with theme variables.
|
|
11
11
|
*/
|
|
12
|
-
export declare function generateDashboardCss(customTheme?: CustomTheme, orientation?: PageOrientation,
|
|
12
|
+
export declare function generateDashboardCss(customTheme?: CustomTheme, orientation?: PageOrientation, embed?: boolean): string;
|
|
13
13
|
/**
|
|
14
14
|
* Generate complete dashboard HTML.
|
|
15
15
|
*/
|
|
16
|
-
export declare function generateDashboardHtml(pageTitle: string, componentsHtml: string[], chartScripts: string[], theme?: string, extraScripts?: string, continuous?: boolean, customTheme?: CustomTheme, orientation?: PageOrientation, embed?: boolean
|
|
16
|
+
export declare function generateDashboardHtml(pageTitle: string, componentsHtml: string[], chartScripts: string[], theme?: string, extraScripts?: string, continuous?: boolean, customTheme?: CustomTheme, orientation?: PageOrientation, embed?: boolean): string;
|
|
17
17
|
/**
|
|
18
18
|
* Generate CSS for the test harness.
|
|
19
19
|
*/
|
package/dist/layout/templates.js
CHANGED
|
@@ -17,17 +17,18 @@ export function escapeHtml(text) {
|
|
|
17
17
|
/**
|
|
18
18
|
* Generate the dashboard CSS with theme variables.
|
|
19
19
|
*/
|
|
20
|
-
export function generateDashboardCss(customTheme, orientation = 'portrait',
|
|
20
|
+
export function generateDashboardCss(customTheme, orientation = 'portrait', embed = false) {
|
|
21
21
|
const colorsLight = getThemeColorsWithCustom('light', customTheme);
|
|
22
22
|
const colorsDark = getThemeColorsWithCustom('dark', customTheme);
|
|
23
23
|
const paletteLight = getPaletteWithCustom('light', customTheme);
|
|
24
24
|
const fonts = getFontsWithCustom(customTheme);
|
|
25
25
|
const linkColor = getLinkColor();
|
|
26
26
|
const dashboardMaxWidth = orientation === 'landscape' ? PRINT_LANDSCAPE_WIDTH : PRINT_PORTRAIT_WIDTH;
|
|
27
|
-
//
|
|
28
|
-
// already supplies its own font stack and the network roundtrip is
|
|
29
|
-
// and the print stylesheet (irrelevant
|
|
30
|
-
|
|
27
|
+
// Embed mode (issues #272 / #247) — skip the Google Fonts `@import` (the
|
|
28
|
+
// host already supplies its own font stack and the network roundtrip is
|
|
29
|
+
// wasted) and the print stylesheet (irrelevant inside iframe-embedded
|
|
30
|
+
// widgets).
|
|
31
|
+
const fontImport = embed ? '' : `@import url('${fonts.import}');`;
|
|
31
32
|
return `
|
|
32
33
|
${fontImport}
|
|
33
34
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -56,7 +57,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait', frag
|
|
|
56
57
|
|
|
57
58
|
html, body {
|
|
58
59
|
width: 100%; min-height: 100%;
|
|
59
|
-
${
|
|
60
|
+
${embed ? '' : 'background-color: var(--bg);'}
|
|
60
61
|
font-family: var(--font-family);
|
|
61
62
|
color: var(--text);
|
|
62
63
|
font-size: 12px;
|
|
@@ -154,7 +155,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait', frag
|
|
|
154
155
|
.row > * { grid-column: span 1; }
|
|
155
156
|
.grid-item { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
|
156
157
|
}
|
|
157
|
-
${
|
|
158
|
+
${embed ? '' : `@media print {
|
|
158
159
|
@page { size: letter ${orientation}; margin: 0.5in; }
|
|
159
160
|
.dashboard { max-width: 100%; padding: 0; }
|
|
160
161
|
.row { gap: 6px; margin-bottom: 6px; break-inside: avoid; page-break-inside: avoid; }
|
|
@@ -167,7 +168,7 @@ export function generateDashboardCss(customTheme, orientation = 'portrait', frag
|
|
|
167
168
|
.title-row { break-after: avoid; page-break-after: avoid; }
|
|
168
169
|
}`}
|
|
169
170
|
.grid-item {
|
|
170
|
-
${
|
|
171
|
+
${embed ? '' : 'background: var(--bg);'}
|
|
171
172
|
padding: 4px;
|
|
172
173
|
transition: background-color 0.3s;
|
|
173
174
|
}
|
|
@@ -362,22 +363,24 @@ export function generateDashboardCss(customTheme, orientation = 'portrait', frag
|
|
|
362
363
|
/**
|
|
363
364
|
* Generate complete dashboard HTML.
|
|
364
365
|
*/
|
|
365
|
-
export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, theme = 'light', extraScripts = '', continuous = false, customTheme, orientation = 'portrait', embed = false
|
|
366
|
+
export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, theme = 'light', extraScripts = '', continuous = false, customTheme, orientation = 'portrait', embed = false) {
|
|
366
367
|
const initialThemeClass = theme === 'dark' ? 'theme-dark' : 'theme-light';
|
|
367
368
|
const continuousClass = continuous ? ' dashboard-continuous' : '';
|
|
368
369
|
const embedClass = embed ? ' dashboard-embed' : '';
|
|
369
|
-
const css = generateDashboardCss(customTheme, orientation,
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
// (document.body mutations, download attribute) and the toggle's button
|
|
373
|
-
// already stripped by embed mode, leaving the function orphaned.
|
|
374
|
-
const exportCss =
|
|
375
|
-
const exportJs =
|
|
376
|
-
// marked.min.js is only needed if a markdown/text component is rendered
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
370
|
+
const css = generateDashboardCss(customTheme, orientation, embed);
|
|
371
|
+
// Embed mode deliberately drops mviz's PNG-export menu and the theme-
|
|
372
|
+
// toggle function: the menu fights iframe-sandbox host behavior
|
|
373
|
+
// (document.body mutations, download attribute) and the toggle's button
|
|
374
|
+
// is already stripped by embed mode, leaving the function orphaned.
|
|
375
|
+
const exportCss = embed ? '' : generateExportCss();
|
|
376
|
+
const exportJs = embed ? '' : generateExportJs();
|
|
377
|
+
// marked.min.js is only needed if a markdown/text component is rendered
|
|
378
|
+
// (textarea + note + text components emit inline scripts that call
|
|
379
|
+
// `marked.parse(...)`). For embed mode we always include the script tag
|
|
380
|
+
// here and let the post-process drop it later — that pass scans the body
|
|
381
|
+
// for `marked.parse` and only strips when no caller survives.
|
|
382
|
+
const markedScript = '<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>';
|
|
383
|
+
const themeToggleJs = embed
|
|
381
384
|
? ''
|
|
382
385
|
: `// Theme toggle function
|
|
383
386
|
function toggleTheme() {
|
|
@@ -445,7 +448,13 @@ export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, t
|
|
|
445
448
|
|
|
446
449
|
${chartScripts.join('')}
|
|
447
450
|
|
|
448
|
-
|
|
451
|
+
// --- mviz sparkline tooltip start ---
|
|
452
|
+
// Always emitted; the embed post-process drops everything between the
|
|
453
|
+
// start/end markers when no .spark-hit element survives the rest of
|
|
454
|
+
// the pipeline, so table sparkline cells in embedded output still get
|
|
455
|
+
// hover handlers. The markers are a delimiter rather than a regex
|
|
456
|
+
// brace-walker because the listener has nested closing braces inside
|
|
457
|
+
// the arrow-function body and a non-balanced regex matches the wrong one.
|
|
449
458
|
document.querySelectorAll('.spark-hit').forEach(hit => {
|
|
450
459
|
hit.addEventListener('mouseenter', function() {
|
|
451
460
|
const tooltip = this.closest('.spark-tooltip');
|
|
@@ -459,7 +468,8 @@ export function generateDashboardHtml(pageTitle, componentsHtml, chartScripts, t
|
|
|
459
468
|
tooltip.setAttribute('data-tooltip', tooltip.dataset.default);
|
|
460
469
|
}
|
|
461
470
|
});
|
|
462
|
-
})
|
|
471
|
+
});
|
|
472
|
+
// --- mviz sparkline tooltip end ---
|
|
463
473
|
|
|
464
474
|
${exportJs}
|
|
465
475
|
</script>
|