@fyeeme/pi-mermaid-viewer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Fixed
6
+ - **Format-switcher menu was invisible.** The `.dl-split` container had `overflow:hidden` (for rounded corners), which clipped the absolutely-positioned `.dl-pop` format menu — it existed in the DOM but was neither visible nor clickable (`elementFromPoint` hit the canvas behind it). Removed `overflow:hidden` and moved the rounding to the individual buttons. SVG/PNG export itself was unaffected.
7
+ - **PNG export silently failed** (download never triggered). Root cause: the `EMOJI_RE` regex was authored inline inside the `renderHtml` template literal, whose backslash-eating corrupted `/[\p{Emoji...}]/gu` into `/[p{Emoji...}]/gu`. As a character class that matches the literal letters p/E/m/o/j/i/..., it stripped ~45% of every exported SVG, so the `<img>` used to rasterize failed to load (`onerror`), `img.onload` never fired, and PNG export hung forever. SVG export was unaffected (it serves the blob directly). Fixed by authoring the regex source at module scope and injecting it, like `quoteBareLabels`.
8
+
9
+ ### Changed
10
+ - **Toolbar: zoom controls moved inline with the main buttons.** The zoom bar is no longer a separate fixed-centered element; it now sits left of the main button group inside the right-aligned toolbar, with an 8px gap between the two groups.
11
+ - **Download: one-click split button.** Replaced the two-step dropdown with a split button: clicking `[⬇ PNG]` downloads PNG immediately; the small `[▾]` caret opens a format picker (PNG/SVG). The chosen format is shown on the main button and remembered across renders via `localStorage` (`mv:dlFormat`).
12
+ - **Toolbar stays right-aligned and no longer wraps** (`white-space:nowrap`, removed `flex-wrap`).
13
+ - **Try-first / fix-on-failure rendering.** Source is now passed to Mermaid verbatim; the bare-label healer (`quoteBareLabels`) runs *only* if Mermaid rejects the original, then retries once. This makes it structurally impossible to corrupt valid source. The previous up-front `sanitize()` re-wrapped already-correct `subgraph ID["…()"]` lines in extra quotes, causing guaranteed parse errors on method names containing `()`.
14
+ - The healer now correctly skips the canonical `subgraph ID[...]` form (not just `subgraph [...]` / `subgraph "..."`).
15
+ - Healed source is surfaced in the split view / copy when a fix succeeds; the notice reads "Auto-fixed" instead of "Sanitized".
16
+
17
+ ### Added
18
+ - Initial release: `/mermaid` command to render Mermaid diagrams from the conversation in the browser.
19
+ - Supports dark, light, and white backgrounds.
20
+ - Zoom controls and 2x PNG export.
21
+ - Split view to see source code alongside rendered diagram.
22
+ - Emoji and special-character sanitization for Mermaid compatibility.
23
+ - Auto-quoting node labels containing `?`, `@`, `<`, `>`, `/`, `&`, `#`, `!` (and `(){}[]`) across all common shapes: rectangle, rhombus, rounded, circle, hexagon, subroutine, cylinder, parallelogram. Fixes parse errors like `B{注解?<br/>@Transactional}`.
24
+ - Emoji support: emoji are now preserved and rendered natively (previously stripped). Verified with Mermaid v11 + `securityLevel: "loose"` + `htmlLabels: true`. Emoji are kept on screen but stripped from SVG/PNG exports for portability.
25
+ - Multi-diagram navigation when conversation contains multiple blocks.
26
+ - Unit tests for the redesign in `test/mermaid-viewer.test.ts` (vitest, 98 cases): `quoteBareLabels` healing/idempotency + the regression that motivated try-first, the `toString()` template-injection invariant (guards the backslash-eating gotcha), and `renderHtml` output validity (`node --check` on the generated `<script>`, healer injected, no stale `sanitize`/`d.fixes`). Added `"test": "vitest --run"` script.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # pi-mermaid-viewer
2
+
3
+ Render Mermaid diagrams found in the conversation as an HTML page opened in the default browser.
4
+
5
+ ## Install
6
+
7
+ Requires the [pi](https://pi.dev) CLI.
8
+
9
+ ### From npm (recommended)
10
+
11
+ ```bash
12
+ # Global (user) install — available in every project
13
+ pi install npm:@fyeeme/pi-mermaid-viewer
14
+
15
+ # Project-local — written to .pi/settings.json, shareable with your team
16
+ pi install -l npm:@fyeeme/pi-mermaid-viewer
17
+
18
+ # Pinned version — skipped by `pi update`
19
+ pi install npm:@fyeeme/pi-mermaid-viewer@1.0.0
20
+
21
+ # Try it once without saving (current run only)
22
+ pi -e npm:@fyeeme/pi-mermaid-viewer
23
+ ```
24
+
25
+ ### From GitHub
26
+
27
+ Source: [`fyeeme/pi-packages`](https://github.com/fyeeme/pi-packages).
28
+
29
+ ```bash
30
+ # HTTPS shorthand
31
+ pi install git:github.com/fyeeme/pi-packages
32
+ # Pin to a tag or commit (skipped by `pi update`)
33
+ pi install git:github.com/fyeeme/pi-packages@v1.0.0
34
+ # Raw URL form
35
+ pi install https://github.com/fyeeme/pi-packages
36
+ ```
37
+
38
+ See the Pi Packages guide on [pi.dev](https://pi.dev) for the full list of source types, scopes, and `pi update` behavior.
39
+
40
+ ## Usage
41
+
42
+ Run `/mermaid` to collect all ````mermaid` code blocks from the conversation and open them in your browser.
43
+
44
+ ### Features
45
+
46
+ - **Dark / Light / White** background themes
47
+ - **Zoom** controls (25% – 400%) with 1:1 reset
48
+ - **2x PNG export** for sharing
49
+ - **Split view** to inspect source alongside rendered diagram
50
+ - **Multi-diagram navigation** when the conversation contains multiple blocks
51
+ - **Emoji support** — emoji render natively via the browser's color font
52
+ - **Auto-quoting** of node labels with special characters (`?`, `@`, `<br/>`, `/`, etc.) that Mermaid cannot parse
53
+ - Opens immediately in your default browser — no local server required
package/index.ts ADDED
@@ -0,0 +1,700 @@
1
+ /**
2
+ * pi-mermaid-viewer
3
+ *
4
+ * Renders Mermaid diagrams found in the conversation as an HTML page
5
+ * opened in the default browser. Supports dark/light themes, zoom,
6
+ * PNG export, and split source view.
7
+ */
8
+
9
+ import { writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { exec, execSync } from "node:child_process";
13
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ export interface DiagramData {
20
+ code: string;
21
+ label: string;
22
+ }
23
+
24
+ export interface MermaidBlock {
25
+ raw: string;
26
+ label: string;
27
+ }
28
+
29
+ // ============================================================================
30
+ // Bare-label healer (runs only after Mermaid fails to parse)
31
+ // ============================================================================
32
+
33
+ // Authored with full TS annotations — at runtime tsx/esbuild strips them, so
34
+ // Function.prototype.toString() yields annotation-free browser-valid JS for
35
+ // injection into the rendered page. Invoked only when the original source
36
+ // fails to parse, so correctly-quoted source is NEVER rewritten. The earlier
37
+ // "sanitize everything up front" design corrupted valid input (e.g. it
38
+ // re-wrapped an already-correct `subgraph L1["…()"]`, producing double
39
+ // quotes and a guaranteed parse error). Try-first makes that class of bug
40
+ // structurally impossible.
41
+ export function quoteBareLabels(code: string): { code: string; fixes: string[] } {
42
+ const fixes: string[] = [];
43
+ const SPECIAL = /[?@<>\/&#!(){}\[\]]/;
44
+ const SHAPES = [
45
+ { o: "(((", c: ")))" },
46
+ { o: "((", c: "))" },
47
+ { o: "{{", c: "}}" },
48
+ { o: "[[", c: "]]" },
49
+ { o: "[(", c: ")]" },
50
+ { o: "[/", c: "/]" },
51
+ { o: "{", c: "}" },
52
+ { o: "(", c: ")" },
53
+ { o: "[", c: "]" },
54
+ ];
55
+ // One alternation per node; longer wrappers first so ((...)) beats (...).
56
+ const SHAPE_RE = /(\w+)(?:\(\(\(([^)]*)\)\)\)|\(\(([^)]*)\)\)|\{\{([^}]*)\}\}|\[\[([^\]]*)\]\]|\[\(([^)]*)\)\]|\[\/([^/]*)\/\]|\{([^}]*)\}|\(([^)]*)\)|\[([^\]]*)\])/g;
57
+
58
+ const fixed = code.split("\n").map((line) => {
59
+ // Subgraph: only heal a genuinely BARE label. Skip the canonical
60
+ // `ID[...]` form, quoted forms, and bracket forms — all already safe.
61
+ const sm = line.match(/^(\s*subgraph\s+)(.+)$/);
62
+ if (sm) {
63
+ const slabel = sm[2].trim();
64
+ if (slabel.charAt(0) === '"' || slabel.charAt(0) === "[" || /^\w+\s*\[/.test(slabel)) return line;
65
+ if (SPECIAL.test(slabel)) {
66
+ fixes.push("subgraph " + slabel);
67
+ return sm[1] + "sg" + fixes.length + ' ["' + slabel + '"]';
68
+ }
69
+ return line;
70
+ }
71
+ // Nodes: quote bare labels containing special chars. Labels that are
72
+ // already double-quoted are left untouched (defence in depth — though
73
+ // this path only runs after a parse failure anyway).
74
+ return line.replace(SHAPE_RE, (match, id, ...groups) => {
75
+ const idx = groups.findIndex((g) => g !== undefined);
76
+ if (idx < 0) return match;
77
+ const shape = SHAPES[idx];
78
+ const label = String(groups[idx]);
79
+ const trimmed = label.trim();
80
+ if (trimmed.charAt(0) === '"' && trimmed.slice(-1) === '"') return match;
81
+ if (!SPECIAL.test(label)) return match;
82
+ fixes.push(id + " " + shape.o + "…" + shape.c);
83
+ const clean = label.replace(/^"+|"+$/g, "").trim();
84
+ return id + shape.o + '"' + clean + '"' + shape.c;
85
+ });
86
+ }).join("\n");
87
+
88
+ return { code: fixed, fixes };
89
+ }
90
+
91
+ // ============================================================================
92
+ // Emoji regex source (authored at module scope to survive template literals)
93
+ // ============================================================================
94
+ // Like quoteBareLabels, this is authored outside the renderHtml template
95
+ // literal because template literals EAT backslashes: inlined as
96
+ // /[\p{Emoji_Presentation}...]/gu it would be corrupted to /[p{Emoji...}]/gu,
97
+ // which (as a character class) matches the letters p, E, m, o, j, i... and
98
+ // strips ~45% of every exported SVG — breaking PNG export (Image fails to
99
+ // load the corrupted SVG, so img.onload never fires). We inject the regex
100
+ // SOURCE string into the template and build the RegExp at runtime.
101
+ const EMOJI_RE_SRC = "[\\\\p{Emoji_Presentation}\\\\p{Extended_Pictographic}\\\\u{FE0F}\\\\u{200D}]";
102
+
103
+ // ============================================================================
104
+ // System theme detection (macOS only)
105
+ // ============================================================================
106
+
107
+ function detectSystemTheme(): "dark" | "light" {
108
+ try {
109
+ const out = execSync("defaults read -g AppleInterfaceStyle 2>/dev/null")
110
+ .toString()
111
+ .trim();
112
+ return out === "Dark" ? "dark" : "light";
113
+ } catch {
114
+ return "light";
115
+ }
116
+ }
117
+
118
+ // ============================================================================
119
+ // HTML page template
120
+ // ============================================================================
121
+
122
+ export function renderHtml(diagrams: DiagramData[], theme: "dark" | "light"): string {
123
+ return `<!DOCTYPE html>
124
+ <html><head><meta charset="utf-8"><title>Mermaid Viewer</title>
125
+ <style>
126
+ *{box-sizing:border-box;margin:0;padding:0}
127
+ html,body{height:100%}
128
+ body{font-family:-apple-system,sans-serif;color:#c9d1d9;padding:0;transition:background .3s}
129
+ .bg-dark{background:#0d1117} .bg-light{background:#f6f8fa} .bg-white{background:#fff}
130
+ .bg-dark{--surface:rgba(22,27,34,.85);--surface-pop:rgba(22,27,34,.95);--field:rgba(48,54,61,.4);--field-hover:rgba(48,54,61,.8);--border:rgba(48,54,61,.6);--text:#c9d1d9;--text-muted:#8b949e;--accent:#58a6ff;--divider:rgba(255,255,255,.12);--shadow:rgba(0,0,0,.3)}
131
+ .bg-light{--surface:rgba(255,255,255,.85);--surface-pop:rgba(255,255,255,.96);--field:rgba(208,215,222,.4);--field-hover:rgba(208,215,222,.8);--border:rgba(208,215,222,.8);--text:#24292f;--text-muted:#57606a;--accent:#0969da;--divider:rgba(0,0,0,.1);--shadow:rgba(0,0,0,.12)}
132
+ .bg-white{--surface:rgba(255,255,255,.9);--surface-pop:rgba(255,255,255,.98);--field:rgba(0,0,0,.05);--field-hover:rgba(0,0,0,.1);--border:rgba(0,0,0,.12);--text:#24292f;--text-muted:#57606a;--accent:#0969da;--divider:rgba(0,0,0,.1);--shadow:rgba(0,0,0,.1)}
133
+ .bg-dark #canvas-viewport{background-image:radial-gradient(circle,rgba(255,255,255,.06) 1px,transparent 1px)}
134
+ .bg-light #canvas-viewport{background-image:radial-gradient(circle,rgba(0,0,0,.08) 1px,transparent 1px)}
135
+ .bg-white #canvas-viewport{background-image:radial-gradient(circle,rgba(0,0,0,.06) 1px,transparent 1px)}
136
+ .bar{position:fixed;top:16px;right:16px;z-index:99;
137
+ display:flex;gap:8px;align-items:center;height:32px;white-space:nowrap;
138
+ background:transparent;backdrop-filter:none;padding:0;border:none;box-shadow:none}
139
+ .bar .grp{display:flex;gap:4px;align-items:center;height:32px;
140
+ background:var(--surface);backdrop-filter:blur(12px);
141
+ padding:4px 10px;border-radius:10px;border:1px solid var(--border);
142
+ box-shadow:0 2px 12px var(--shadow)}
143
+ .bar .title{display:none}
144
+ .bar button,.bar select{padding:3px 8px;border:none;border-radius:6px;font-size:11px;font-weight:600;
145
+ cursor:pointer;color:var(--text);transition:.15s;background:var(--field);line-height:1;
146
+ display:inline-flex;align-items:center;gap:4px}
147
+ .bar button:hover,.bar select:hover{background:var(--field-hover)}
148
+ .bar button svg,.bar button img{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}
149
+ .bc:hover{background:#e94560!important}
150
+ /* Generic dropdown trigger (shared by theme + format selectors) */
151
+ .dd{position:relative;display:inline-flex;align-items:stretch;background:var(--field);border-radius:6px}
152
+ .dd-btn{padding:3px 8px;border:none;border-radius:6px;cursor:pointer;color:var(--text);
153
+ font-size:11px;font-weight:600;display:inline-flex;align-items:center;gap:4px;background:transparent;transition:.15s}
154
+ .dd-btn:hover{background:var(--field-hover)}
155
+ .dd-caret{width:10px;height:10px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s}
156
+ .dd-caret.spin{transform:rotate(180deg)}
157
+ .dd-pop{position:absolute;top:calc(100% + 4px);right:0;z-index:100;min-width:100%;
158
+ background:var(--surface-pop);backdrop-filter:blur(12px);
159
+ border:1px solid var(--border);border-radius:8px;padding:4px;
160
+ box-shadow:0 8px 24px var(--shadow);display:none}
161
+ .dd-pop.open{display:block}
162
+ .dd-pop button{display:flex;width:100%;align-items:center;gap:8px;
163
+ padding:5px 8px;border-radius:5px;color:var(--text);background:transparent;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap}
164
+ .dd-pop button:hover{background:var(--field-hover)}
165
+ .dd-pop button.active{color:var(--accent)}
166
+ /* Download split button: main (download) + caret (switch), reusing dd styling.
167
+ Selector is prefixed with .bar so its specificity ties .bar button and wins
168
+ by source order (these rules come later), otherwise the universal
169
+ .bar button{border:none;border-radius:6px} would flatten the half-round
170
+ corners and erase the divider. */
171
+ .dl-split{display:inline-flex;align-items:stretch;background:var(--field);border-radius:6px}
172
+ .bar .dl-main{padding:3px 8px;border:none;border-radius:6px 0 0 6px;cursor:pointer;color:var(--text);
173
+ font-size:11px;font-weight:600;display:inline-flex;align-items:center;gap:4px;background:transparent;transition:.15s}
174
+ .bar .dl-main:hover{background:var(--field-hover)}
175
+ .dl-main svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
176
+ .bar .dl-toggle{padding:3px 6px;border:none;border-radius:0 6px 6px 0;border-left:1px solid var(--divider);
177
+ cursor:pointer;color:var(--text);background:transparent;display:inline-flex;align-items:center;transition:.15s}
178
+ .bar .dl-toggle:hover{background:var(--field-hover)}
179
+ .dl-toggle svg{width:10px;height:10px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
180
+ /* zoom-bar now lives inside .bar as a .grp; legacy fixed positioning removed */
181
+ .tabs{position:fixed;top:16px;left:16px;right:auto;z-index:98;
182
+ display:flex;gap:4px;align-items:center;height:32px;padding:0;overflow-x:auto;
183
+ max-width:calc(100vw - 320px)}
184
+ .tabs:empty{display:none}
185
+ .tab{padding:5px 12px;font-size:11px;font-weight:600;color:#8b949e;cursor:pointer;
186
+ background:rgba(22,27,34,.85);backdrop-filter:blur(12px);
187
+ border:1px solid rgba(48,54,61,.6);border-radius:8px;
188
+ white-space:nowrap;transition:.15s;line-height:1}
189
+ .tab:hover{color:#c9d1d9;background:rgba(48,54,61,.6)}
190
+ .tab.active{color:#58a6ff;background:rgba(31,111,235,.2);border-color:rgba(88,166,255,.4)}
191
+ .content{display:flex;min-height:100vh}
192
+ .content.split #canvas-viewport{border-right:1px solid #30363d}
193
+ #canvas-viewport{flex:1;overflow:hidden;padding:60px 24px 24px 24px;cursor:grab;user-select:none;
194
+ background-image:radial-gradient(circle,rgba(128,128,128,.15) 1px,transparent 1px);
195
+ background-size:20px 20px;text-align:center;position:relative}
196
+ #canvas-viewport>*+*{margin-top:12px}
197
+ #canvas-viewport:active{cursor:grabbing}
198
+ #canvas-viewport.dragging{cursor:grabbing;scroll-behavior:auto}
199
+ #src-wrap{display:none;flex:1;overflow:hidden;border-left:1px solid #30363d}
200
+ .content.split #src-wrap{display:flex;flex-direction:column}
201
+ #src{white-space:pre-wrap;font-family:"SF Mono",monospace;font-size:12px;
202
+ line-height:1.7;background:#161b22;padding:16px;user-select:all;flex:1;overflow:auto;color:#c9d1d9}
203
+ #svg-wrap{display:inline-block;position:relative;left:0;top:0;vertical-align:top}
204
+ #svg-wrap svg{max-width:100%;height:auto;display:block}
205
+ .loading{color:#8b949e;font-size:14px;padding:40px 0;width:100%}
206
+ .fixes{position:fixed;bottom:16px;right:16px;z-index:10;
207
+ background:rgba(28,18,4,.85);backdrop-filter:blur(8px);
208
+ border:1px solid rgba(187,128,9,.4);border-radius:8px;padding:6px 12px;
209
+ font-size:11px;color:#d29922;max-width:320px;display:none}
210
+ .err{background:#1c0a0a;border:1px solid #f85149;border-radius:6px;padding:16px;
211
+ color:#f85149;font-family:monospace;white-space:pre-wrap;display:none;margin-bottom:12px;max-width:960px;width:100%}
212
+ .src-header{padding:8px 16px;background:#161b22;border-bottom:1px solid #30363d;
213
+ display:flex;align-items:center;gap:8px}
214
+ .src-header span{color:#8b949e;font-size:12px;flex:1}
215
+ .src-header button{background:#30363d;border:none;border-radius:4px;color:#c9d1d9;
216
+ padding:4px 10px;font-size:11px;cursor:pointer}
217
+ .src-header button:hover{background:#484f58}
218
+ .split-toggle{background:#6e40c9!important}.split-toggle:hover{background:#8957e5!important}
219
+ .zoom-bar{display:flex;align-items:center;gap:4px;height:32px;padding:4px 10px;border-radius:10px;
220
+ background:rgba(22,27,34,.85);backdrop-filter:blur(12px);
221
+ border:1px solid rgba(48,54,61,.6);box-shadow:0 2px 12px rgba(0,0,0,.3)}
222
+ .zoom-bar button{background:transparent;border:none;border-radius:5px;width:24px;height:24px;
223
+ padding:2px;cursor:pointer;color:#8b949e;display:flex;align-items:center;justify-content:center;
224
+ transition:all .15s}
225
+ .zoom-bar button:hover{color:#c9d1d9;background:rgba(48,54,61,.5)}
226
+ .zoom-bar button:disabled{opacity:.3;pointer-events:none}
227
+ .zoom-bar button:focus-visible{outline:2px solid #58a6ff;outline-offset:2px}
228
+ .zoom-bar span{color:#8b949e;font-size:11px;min-width:36px;text-align:center;
229
+ font-variant-numeric:tabular-nums;line-height:1}
230
+ .zoom-bar svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;
231
+ stroke-linecap:round;stroke-linejoin:round}
232
+ </style></head><body class="bg-${theme}">
233
+ <div class="bar">
234
+ <span id="title" style="display:none"></span>
235
+ <div class="grp" id="zb">
236
+ <button id="zout" title="Zoom out" aria-label="Zoom out" onclick="zoom(-10)">
237
+ <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/><path d="M8 11h6"/></svg>
238
+ </button>
239
+ <span id="zl" aria-live="polite" role="status">100%</span>
240
+ <button id="zin" title="Zoom in" aria-label="Zoom in" onclick="zoom(10)">
241
+ <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/><path d="M8 11h6"/><path d="M11 8v6"/></svg>
242
+ </button>
243
+ <button id="zreset" title="Reset view" aria-label="Reset view" onclick="zoom(0)">
244
+ <svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
245
+ </button>
246
+ </div>
247
+ <div class="grp">
248
+ <span class="dd" id="bg_dd">
249
+ <button class="dd-btn" id="bg_btn" title="Theme" aria-label="Theme" aria-haspopup="true">
250
+ <span id="bg_label">Dark</span>
251
+ <svg class="dd-caret" id="bg_caret" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
252
+ </button>
253
+ <div class="dd-pop" id="bg_pop">
254
+ <button data-bg="dark">Dark</button>
255
+ <button data-bg="light">Light</button>
256
+ <button data-bg="white">White</button>
257
+ </div>
258
+ </span>
259
+ <span class="dl-split" id="dl_split" style="position:relative"><button class="dl-main" id="dlm_main" title="Download PNG" aria-label="Download"><svg viewBox="0 0 24 24"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg><span id="dlm_fmt">PNG</span></button><button class="dl-toggle" id="dlm_tgl" title="Switch format" aria-label="Switch format" aria-haspopup="true"><svg class="dd-caret" id="dlm_caret" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg></button><div class="dd-pop" id="dlm"><button data-fmt="png"><span>PNG</span></button><button data-fmt="svg"><span>SVG</span></button></div></span>
260
+ <button class="bc" id="cb" onclick="copyCode()">Copy</button>
261
+ <button class="bs" id="sb" onclick="toggleSrc()">Split</button>
262
+ </div>
263
+ </div>
264
+ <div class="tabs" id="tabs"></div>
265
+ <div class="content" id="content">
266
+ <div id="canvas-viewport">
267
+ <div class="loading" id="ld">Loading diagram...</div>
268
+ <div class="fixes" id="fx"></div>
269
+ <div class="err" id="er"></div>
270
+ <div id="svg-wrap"><div id="svg"></div></div>
271
+ </div>
272
+ <div id="src-wrap">
273
+ <div class="src-header">
274
+ <span id="srcLabel">SOURCE</span>
275
+ <button onclick="copySrc()">Copy</button>
276
+ </div>
277
+ <div id="src"></div>
278
+
279
+ </div>
280
+ </div>
281
+ <script type="module">
282
+ const DIAGRAMS = ${JSON.stringify(diagrams)};
283
+ const INIT_BG = "${theme}";
284
+
285
+ let zoomLevel = 100;
286
+ let svgNaturalW = -1, svgNaturalH = -1;
287
+ let mermaidLib = null;
288
+ let currentBg = INIT_BG;
289
+ let activeIdx = 0;
290
+
291
+ const bgClass = { dark: "bg-dark", light: "bg-light", white: "bg-white" };
292
+ const bgFill = { dark: "#0d1117", light: "#f6f8fa", white: "#ffffff" };
293
+ const themeMap = { dark: "dark", light: "default", white: "base" };
294
+ const EMOJI_RE = new RegExp("${EMOJI_RE_SRC}", "gu");
295
+
296
+ document.getElementById("bg_label").textContent = INIT_BG.charAt(0).toUpperCase() + INIT_BG.slice(1);
297
+ document.querySelectorAll("#bg_pop button[data-bg]").forEach(function(b){
298
+ b.classList.toggle("active", b.getAttribute("data-bg") === INIT_BG);
299
+ });
300
+
301
+ if (DIAGRAMS.length > 1) {
302
+ DIAGRAMS.forEach((d, i) => {
303
+ const t = document.createElement("div");
304
+ t.className = "tab" + (i === 0 ? " active" : "");
305
+ t.textContent = d.label;
306
+ t.onclick = () => switchTab(i);
307
+ document.getElementById("tabs").appendChild(t);
308
+ });
309
+ }
310
+
311
+ function updateZoomButtons() {
312
+ document.getElementById("zin").disabled = zoomLevel >= 400;
313
+ document.getElementById("zout").disabled = zoomLevel <= 25;
314
+ }
315
+
316
+ function switchTab(idx) {
317
+ activeIdx = idx;
318
+ document.querySelectorAll(".tab").forEach((t, i) =>
319
+ t.classList.toggle("active", i === idx));
320
+ zoomLevel = 100;
321
+ updateZoomButtons();
322
+ if (window.resetPan) window.resetPan();
323
+ render(themeMap[currentBg] || "dark");
324
+ document.getElementById("srcLabel").textContent = "SOURCE #" + (idx + 1);
325
+ }
326
+
327
+ ${quoteBareLabels.toString()}
328
+
329
+ async function render(theme) {
330
+ const d = DIAGRAMS[activeIdx];
331
+ document.getElementById("ld").style.display = "block";
332
+ document.getElementById("svg").innerHTML = "";
333
+ document.getElementById("fx").style.display = "none";
334
+ document.getElementById("er").style.display = "none";
335
+
336
+ // Try-first: render the user's source verbatim. Only if Mermaid rejects it
337
+ // do we run quoteBareLabels() and retry once. Valid source is never rewritten.
338
+ let svg = null, fixes = null, err = null;
339
+ try {
340
+ if (!mermaidLib) {
341
+ mermaidLib = (await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs")).default;
342
+ }
343
+ mermaidLib.initialize({startOnLoad:false,theme:theme,securityLevel:"loose",
344
+ themeVariables: theme === "dark" ? {} : { fontSize:"14px", fontFamily:"-apple-system,sans-serif" },
345
+ flowchart:{useMaxWidth:true,htmlLabels:true,curve:"basis"}});
346
+ try {
347
+ svg = (await mermaidLib.render("m" + Date.now(), d.code)).svg;
348
+ } catch (e1) {
349
+ const healed = quoteBareLabels(d.code);
350
+ if (!healed.fixes.length) throw e1; // nothing to heal → surface original error
351
+ svg = (await mermaidLib.render("m" + Date.now() + "f", healed.code)).svg;
352
+ d.code = healed.code; // surface healed source in split view / copy
353
+ fixes = healed.fixes;
354
+ }
355
+ } catch (e) {
356
+ err = e;
357
+ }
358
+
359
+ document.getElementById("ld").style.display = "none";
360
+ if (err) {
361
+ const el = document.getElementById("er");
362
+ el.textContent = err.message; el.style.display = "block";
363
+ document.getElementById("title").textContent = "Error";
364
+ } else {
365
+ document.getElementById("svg").innerHTML = svg;
366
+ document.getElementById("title").textContent = d.label;
367
+
368
+ const svgEl = document.querySelector("#svg svg");
369
+ if (svgEl) {
370
+ svgNaturalW = svgEl.viewBox?.baseVal?.width || svgEl.getBBox?.().width || 400;
371
+ svgNaturalH = svgEl.viewBox?.baseVal?.height || svgEl.getBBox?.().height || 300;
372
+ svgEl.setAttribute("width", String(svgNaturalW));
373
+ svgEl.setAttribute("height", String(svgNaturalH));
374
+ const wrap = document.getElementById("svg-wrap");
375
+ wrap.style.width = svgNaturalW + "px";
376
+ wrap.style.maxWidth = "";
377
+ }
378
+
379
+ const fxEl = document.getElementById("fx");
380
+ if (fixes && fixes.length) {
381
+ fxEl.innerHTML = "<strong>Auto-fixed:</strong> " + fixes.map(f => f.replace(/&/g,"&amp;").replace(/</g,"&lt;")).join(" &middot; ");
382
+ fxEl.style.display = "block";
383
+ }
384
+ }
385
+ document.getElementById("src").textContent = d.code;
386
+ }
387
+ render(themeMap[INIT_BG] || "dark");
388
+ updateZoomButtons();
389
+
390
+ function applyZoom() {
391
+ const svgEl = document.querySelector("#svg svg");
392
+ if (!svgEl || svgNaturalW <= 0) return;
393
+ const s = zoomLevel / 100;
394
+ const zw = Math.round(svgNaturalW * s);
395
+ const zh = Math.round(svgNaturalH * s);
396
+ svgEl.setAttribute("width", String(zw));
397
+ svgEl.setAttribute("height", String(zh));
398
+ svgEl.style.maxWidth = zoomLevel > 100 ? "none" : "";
399
+ const wrap = document.getElementById("svg-wrap");
400
+ wrap.style.width = zw + "px";
401
+ wrap.style.maxWidth = zoomLevel > 100 ? "none" : "";
402
+ document.getElementById("zl").textContent = zoomLevel + "%";
403
+ updateZoomButtons();
404
+ }
405
+
406
+ window.zoom = function(delta) {
407
+ if (delta === 0) {
408
+ zoomLevel = 100;
409
+ if (window.resetPan) window.resetPan();
410
+ } else {
411
+ zoomLevel = Math.max(25, Math.min(400, zoomLevel + delta));
412
+ }
413
+ applyZoom();
414
+ };
415
+
416
+ // Ctrl+scroll = zoom (intercept before browser handles it)
417
+ window.addEventListener("wheel", function(e) {
418
+ if (e.ctrlKey || e.metaKey) {
419
+ e.preventDefault();
420
+ zoomLevel = Math.max(25, Math.min(400, zoomLevel + (e.deltaY < 0 ? 10 : -10)));
421
+ applyZoom();
422
+ }
423
+ }, { passive: false });
424
+
425
+ window.setBg = function(v) {
426
+ currentBg = v;
427
+ document.body.className = bgClass[v] || "bg-dark";
428
+ // sync the theme dropdown label + active item
429
+ document.getElementById("bg_label").textContent = (v.charAt(0).toUpperCase() + v.slice(1));
430
+ document.querySelectorAll("#bg_pop button[data-bg]").forEach(function(b){
431
+ b.classList.toggle("active", b.getAttribute("data-bg") === v);
432
+ });
433
+ closeAllPops();
434
+ render(themeMap[v] || "dark");
435
+ };
436
+
437
+ function getSvgEl() { return document.querySelector("#svg svg"); }
438
+
439
+ function svgNaturalSize(el) {
440
+ const vb = el.viewBox?.baseVal;
441
+ if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };
442
+ const bbox = el.getBBox?.();
443
+ if (bbox) return { w: bbox.width, h: bbox.height };
444
+ return { w: el.getBoundingClientRect().width, h: el.getBoundingClientRect().height };
445
+ }
446
+
447
+ window.exportSvg = function() {
448
+ const svgEl = getSvgEl();
449
+ if (!svgEl) { alert("No diagram to export"); return; }
450
+ const d = DIAGRAMS[activeIdx];
451
+ const clone = svgEl.cloneNode(true);
452
+ const { w, h } = svgNaturalSize(svgEl);
453
+ clone.setAttribute("width", w);
454
+ clone.setAttribute("height", h);
455
+ clone.style.background = bgFill[currentBg] || "#0d1117";
456
+ const data = new XMLSerializer().serializeToString(clone).replace(EMOJI_RE, "");
457
+ const blob = new Blob([data], { type: "image/svg+xml" });
458
+ const a = document.createElement("a");
459
+ a.download = (d.label || "mermaid") + "-" + Date.now() + ".svg";
460
+ a.href = URL.createObjectURL(blob);
461
+ a.click();
462
+ URL.revokeObjectURL(a.href);
463
+ };
464
+
465
+ window.exportPng = function() {
466
+ const svgEl = getSvgEl();
467
+ if (!svgEl) { alert("No diagram to export"); return; }
468
+ const d = DIAGRAMS[activeIdx];
469
+ const { w, h } = svgNaturalSize(svgEl);
470
+ const scale = 3;
471
+ const canvas = document.createElement("canvas");
472
+ canvas.width = w * scale; canvas.height = h * scale;
473
+ const ctx = canvas.getContext("2d");
474
+ ctx.fillStyle = bgFill[currentBg] || "#0d1117";
475
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
476
+ const data = new XMLSerializer().serializeToString(svgEl).replace(EMOJI_RE, "");
477
+ const img = new Image();
478
+ img.onload = function() {
479
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
480
+ const a = document.createElement("a");
481
+ a.download = (d.label || "mermaid") + "-" + Date.now() + ".png";
482
+ a.href = canvas.toDataURL("image/png");
483
+ a.click();
484
+ };
485
+ img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(data);
486
+ };
487
+
488
+ // Shared dropdown helpers — both theme (.dd) and format (.dl-split) popups
489
+ // use .dd-pop.open + a .dd-caret.spin that must be reset together.
490
+ function closeAllPops(){
491
+ document.querySelectorAll(".dd-pop.open").forEach(function(p){ p.classList.remove("open"); });
492
+ document.querySelectorAll(".dd-caret.spin").forEach(function(c){ c.classList.remove("spin"); });
493
+ }
494
+
495
+ // Download split button: one-click download + format switcher.
496
+ let dlFormat = "png";
497
+ try { dlFormat = localStorage.getItem("mv:dlFormat") === "svg" ? "svg" : "png"; } catch {}
498
+ function toggleDlMenu(){
499
+ const pop = document.getElementById("dlm");
500
+ const willOpen = !pop.classList.contains("open");
501
+ closeAllPops();
502
+ if (willOpen) { pop.classList.add("open"); document.getElementById("dlm_caret").classList.add("spin"); }
503
+ }
504
+ function applyFormat(){
505
+ const fmt = dlFormat.toUpperCase();
506
+ document.getElementById("dlm_fmt").textContent = fmt;
507
+ document.getElementById("dlm_main").title = "Download " + fmt;
508
+ document.querySelectorAll("#dlm button[data-fmt]").forEach(function(b){
509
+ b.classList.toggle("active", b.getAttribute("data-fmt") === dlFormat);
510
+ });
511
+ }
512
+ function doDownload(){
513
+ closeAllPops();
514
+ (dlFormat === "svg" ? window.exportSvg : window.exportPng)();
515
+ }
516
+ function setFormat(fmt){
517
+ dlFormat = fmt;
518
+ try { localStorage.setItem("mv:dlFormat", fmt); } catch {}
519
+ applyFormat();
520
+ closeAllPops();
521
+ }
522
+ // Event delegation on the format split button.
523
+ document.getElementById("dl_split").addEventListener("click", function(e){
524
+ e.stopPropagation();
525
+ if (e.target.closest(".dl-main")) { doDownload(); return; }
526
+ if (e.target.closest(".dl-toggle")) { toggleDlMenu(); return; }
527
+ const item = e.target.closest("[data-fmt]");
528
+ if (item) { setFormat(item.getAttribute("data-fmt")); return; }
529
+ });
530
+ // Theme dropdown: open/close + pick.
531
+ document.getElementById("bg_dd").addEventListener("click", function(e){
532
+ e.stopPropagation();
533
+ const item = e.target.closest("[data-bg]");
534
+ if (item) { window.setBg(item.getAttribute("data-bg")); return; }
535
+ if (e.target.closest(".dd-btn")) {
536
+ const pop = document.getElementById("bg_pop");
537
+ const willOpen = !pop.classList.contains("open");
538
+ closeAllPops();
539
+ if (willOpen) { pop.classList.add("open"); document.getElementById("bg_caret").classList.add("spin"); }
540
+ }
541
+ });
542
+ // Close any open popup when clicking outside both dropdowns.
543
+ document.addEventListener("click", function(e) {
544
+ if (!e.target.closest(".dl-split") && !e.target.closest(".dd")) closeAllPops();
545
+ });
546
+ applyFormat();
547
+
548
+ window.copyCode = function() {
549
+ navigator.clipboard.writeText(DIAGRAMS[activeIdx].code).then(() => {
550
+ const b = document.getElementById("cb"); b.textContent = "Copied!";
551
+ setTimeout(() => b.textContent = "Copy", 1500);
552
+ });
553
+ };
554
+ window.copySrc = function() {
555
+ navigator.clipboard.writeText(DIAGRAMS[activeIdx].code).then(function() {
556
+ var b = document.querySelector(".src-header button");
557
+ b.textContent = "Copied!";
558
+ setTimeout(function() { b.textContent = "Copy"; }, 1500);
559
+ });
560
+ };
561
+
562
+ // Drag-to-pan — global vars for reliable access
563
+ let _panX = 0, _panY = 0;
564
+ let _dragging = false, _mx = 0, _my = 0, _sx = 0, _sy = 0;
565
+ function _applyPan() {
566
+ const w = document.getElementById("svg-wrap");
567
+ if (w) { w.style.left = _panX + "px"; w.style.top = _panY + "px"; }
568
+ }
569
+ document.addEventListener("mousedown", function(e) {
570
+ if (e.button > 0) return;
571
+ if (e.target.closest("button") || e.target.closest("select")) return;
572
+ if (!document.getElementById("canvas-viewport").contains(e.target)) return;
573
+ e.preventDefault();
574
+ _dragging = true;
575
+ _mx = e.pageX; _my = e.pageY;
576
+ _sx = _panX; _sy = _panY;
577
+ document.getElementById("canvas-viewport").classList.add("dragging");
578
+ });
579
+ document.addEventListener("mousemove", function(e) {
580
+ if (!_dragging) return;
581
+ _panX = _sx + e.pageX - _mx;
582
+ _panY = _sy + e.pageY - _my;
583
+ _applyPan();
584
+ });
585
+ document.addEventListener("pointermove", function(e) {
586
+ if (!_dragging) return;
587
+ _panX = _sx + e.pageX - _mx;
588
+ _panY = _sy + e.pageY - _my;
589
+ _applyPan();
590
+ });
591
+ document.addEventListener("mouseup", function() {
592
+ if (!_dragging) return;
593
+ _dragging = false;
594
+ const vp = document.getElementById("canvas-viewport");
595
+ if (vp) vp.classList.remove("dragging");
596
+ });
597
+ window.resetPan = function() { _panX = 0; _panY = 0; _applyPan(); };
598
+ document.getElementById("svg-wrap").style.position = "relative";
599
+
600
+ window.toggleSrc = function() {
601
+ const c = document.getElementById("content");
602
+ const b = document.getElementById("sb");
603
+ if (c.classList.contains("split")) {
604
+ c.classList.remove("split");
605
+ b.textContent = "Split";
606
+ } else {
607
+ c.classList.add("split");
608
+ b.textContent = "Close";
609
+ }
610
+ };
611
+ </script></body></html>`;
612
+ }
613
+
614
+ // ============================================================================
615
+ // Browser opening
616
+ // ============================================================================
617
+
618
+ function openInBrowser(path: string, ctx: ExtensionCommandContext): void {
619
+ const cmd =
620
+ process.platform === "win32"
621
+ ? "start"
622
+ : process.platform === "linux"
623
+ ? "xdg-open"
624
+ : "open";
625
+ exec(`${cmd} "${path}"`, (err) => {
626
+ if (err) {
627
+ ctx.ui.notify(`Failed to open browser: ${err.message}`, "error");
628
+ } else {
629
+ ctx.ui.notify("Opened in browser!", "info");
630
+ }
631
+ });
632
+ }
633
+
634
+ // ============================================================================
635
+ // Mermaid block extraction from session
636
+ // ============================================================================
637
+
638
+ export function extractMermaidBlocks(ctx: ExtensionCommandContext): MermaidBlock[] {
639
+ const blocks: MermaidBlock[] = [];
640
+ let idx = 0;
641
+
642
+ for (const entry of ctx.sessionManager.getEntries()) {
643
+ if (entry.type !== "message") continue;
644
+ const msg = entry.message;
645
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
646
+ for (const block of msg.content) {
647
+ if (block.type !== "text" || !block.text) continue;
648
+ for (const m of block.text.matchAll(/```mermaid\s*\n([\s\S]*?)```/g)) {
649
+ idx += 1;
650
+ blocks.push({ raw: m[1].trim(), label: `Diagram ${idx}` });
651
+ }
652
+ }
653
+ }
654
+
655
+ return blocks;
656
+ }
657
+
658
+ export function labelBlocks(blocks: MermaidBlock[]): void {
659
+ blocks.reverse();
660
+ blocks.forEach((b, i) => {
661
+ if (blocks.length === 1) {
662
+ b.label = "Diagram";
663
+ } else {
664
+ b.label = `#${blocks.length - i}${i === 0 ? " (latest)" : ""}`;
665
+ }
666
+ });
667
+ }
668
+
669
+ // ============================================================================
670
+ // Extension
671
+ // ============================================================================
672
+
673
+ export default function (pi: ExtensionAPI): void {
674
+ pi.registerCommand("mermaid", {
675
+ description: "Render Mermaid diagrams from the conversation in the browser",
676
+
677
+ handler: async (_args, ctx) => {
678
+ const blocks = extractMermaidBlocks(ctx);
679
+
680
+ if (blocks.length === 0) {
681
+ ctx.ui.notify("No Mermaid diagrams found in the conversation.", "error");
682
+ return;
683
+ }
684
+
685
+ labelBlocks(blocks);
686
+
687
+ const diagrams: DiagramData[] = blocks.map((b) => ({
688
+ code: b.raw,
689
+ label: b.label,
690
+ }));
691
+
692
+ const theme = detectSystemTheme();
693
+ const html = renderHtml(diagrams, theme);
694
+ const path = join(tmpdir(), `mermaid-${Date.now()}.html`);
695
+
696
+ writeFileSync(path, html, "utf-8");
697
+ openInBrowser(path, ctx);
698
+ },
699
+ });
700
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@fyeeme/pi-mermaid-viewer",
3
+ "version": "1.0.0",
4
+ "description": "Render Mermaid diagrams from the conversation in the browser.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "fyeeme",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/fyeeme/pi-packages"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/fyeeme/pi-packages/issues"
17
+ },
18
+ "homepage": "https://github.com/fyeeme/pi-packages#readme",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi",
25
+ "mermaid",
26
+ "diagram",
27
+ "viewer"
28
+ ],
29
+ "files": [
30
+ "index.ts",
31
+ "README.md",
32
+ "LICENSE",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "pi": {
36
+ "extensions": [
37
+ "./index.ts"
38
+ ]
39
+ },
40
+ "scripts": {
41
+ "test": "vitest --run",
42
+ "typecheck": "tsc"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": ">=0.77.0"
46
+ },
47
+ "devDependencies": {
48
+ "@earendil-works/pi-coding-agent": "0.77.0",
49
+ "@types/node": "22.19.19",
50
+ "typescript": "5.9.3"
51
+ }
52
+ }