Skip to main content

sql_splitter/graph/format/
html.rs

1//! HTML format output with embedded Mermaid ERD and sql-splitter branding.
2
3use crate::graph::format::mermaid;
4use crate::graph::view::GraphView;
5
6/// Generate interactive HTML with embedded Mermaid ERD and dark/light mode toggle
7pub fn to_html(view: &GraphView, title: &str) -> String {
8    let mermaid_code = mermaid::to_mermaid(view);
9
10    let total_columns: usize = view.sorted_tables().iter().map(|t| t.columns.len()).sum();
11    let stats = format!(
12        "{} tables · {} columns · {} relationships",
13        view.table_count(),
14        total_columns,
15        view.edge_count()
16    );
17
18    format!(
19        r##"<!DOCTYPE html>
20<html lang="en">
21<head>
22  <meta charset="UTF-8">
23  <meta name="viewport" content="width=device-width, initial-scale=1.0">
24  <title>{title}</title>
25  <script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
26  <script src="https://cdn.jsdelivr.net/npm/panzoom@9/dist/panzoom.min.js"></script>
27  <link rel="preconnect" href="https://fonts.googleapis.com">
28  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
29  <link href="https://fonts.googleapis.com/css2?family=Monda:wght@400;700&display=swap" rel="stylesheet">
30  <style>
31    :root {{
32      --color-bg: #0a0a0a;
33      --color-surface: #111111;
34      --color-text: #e6edf3;
35      --color-text-muted: #8b949e;
36      --color-border: #27272a;
37      --color-accent: #58a6ff;
38    }}
39
40    [data-theme="light"] {{
41      --color-bg: #ffffff;
42      --color-surface: #f6f8fa;
43      --color-text: #1f2328;
44      --color-text-muted: #656d76;
45      --color-border: #d0d7de;
46      --color-accent: #0969da;
47    }}
48
49    * {{ box-sizing: border-box; margin: 0; padding: 0; }}
50    html, body {{ height: 100%; overflow: hidden; }}
51
52    body {{
53      font-family: 'Monda', -apple-system, BlinkMacSystemFont, sans-serif;
54      background: var(--color-bg);
55      color: var(--color-text);
56      transition: background-color 0.2s, color 0.2s;
57    }}
58
59    .diagram-container {{
60      position: absolute;
61      top: 0;
62      left: 0;
63      right: 0;
64      bottom: 44px;
65      overflow: hidden;
66      cursor: grab;
67    }}
68
69    .diagram-container:active {{
70      cursor: grabbing;
71    }}
72
73    .mermaid {{
74      display: inline-block;
75      transform-origin: 0 0;
76    }}
77
78    .mermaid svg {{
79      max-width: none !important;
80    }}
81
82    .bottom-bar {{
83      position: fixed;
84      bottom: 0;
85      left: 0;
86      right: 0;
87      height: 44px;
88      background: var(--color-surface);
89      border-top: 1px solid var(--color-border);
90      display: flex;
91      justify-content: space-between;
92      align-items: center;
93      padding: 0 16px;
94      font-size: 13px;
95    }}
96
97    .bar-left {{
98      display: flex;
99      align-items: center;
100      gap: 12px;
101    }}
102
103    .logo {{
104      display: flex;
105      align-items: center;
106      gap: 6px;
107      text-decoration: none;
108      color: var(--color-text);
109      font-weight: 700;
110    }}
111
112    .logo-icon {{
113      font-size: 1.3em;
114      color: var(--color-accent);
115    }}
116
117    .sep {{
118      color: var(--color-border);
119    }}
120
121    .title {{
122      color: var(--color-text-muted);
123    }}
124
125    .bar-right {{
126      display: flex;
127      align-items: center;
128      gap: 12px;
129    }}
130
131    .stats {{
132      color: var(--color-text-muted);
133    }}
134
135    .btn {{
136      background: none;
137      border: 1px solid var(--color-border);
138      border-radius: 4px;
139      padding: 5px 10px;
140      cursor: pointer;
141      color: var(--color-text-muted);
142      font-family: inherit;
143      font-size: 12px;
144      display: flex;
145      align-items: center;
146      gap: 5px;
147      transition: border-color 0.15s, color 0.15s;
148    }}
149
150    .btn:hover {{
151      border-color: var(--color-accent);
152      color: var(--color-accent);
153    }}
154
155    .btn.copied {{
156      border-color: #3fb950;
157      color: #3fb950;
158    }}
159
160    .btn svg {{
161      width: 14px;
162      height: 14px;
163    }}
164
165    .icon-btn {{
166      background: none;
167      border: none;
168      padding: 6px;
169      cursor: pointer;
170      color: var(--color-text-muted);
171      display: flex;
172      transition: color 0.15s;
173    }}
174
175    .icon-btn:hover {{
176      color: var(--color-accent);
177    }}
178
179    .icon-btn svg {{
180      width: 16px;
181      height: 16px;
182    }}
183
184    .icon-sun {{ display: none; }}
185    .icon-moon {{ display: block; }}
186    [data-theme="light"] .icon-sun {{ display: block; }}
187    [data-theme="light"] .icon-moon {{ display: none; }}
188
189    @media (max-width: 600px) {{
190      .title, .stats {{ display: none; }}
191    }}
192  </style>
193</head>
194<body data-theme="dark">
195  <div class="diagram-container">
196    <div class="mermaid" id="diagram">
197{mermaid_code}
198    </div>
199  </div>
200
201  <div class="bottom-bar">
202    <div class="bar-left">
203      <a href="https://github.com/helgesverre/sql-splitter" class="logo" target="_blank" title="sql-splitter">
204        <span class="logo-icon">;</span>
205        <span>sql-splitter</span>
206      </a>
207      <span class="sep">·</span>
208      <span class="title">{title}</span>
209    </div>
210
211    <div class="bar-right">
212      <span class="stats">{stats}</span>
213      <button class="btn" id="copyBtn" onclick="copyMermaid()" title="Copy Mermaid code">
214        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
215          <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
216          <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
217        </svg>
218        <span id="copyText">Copy</span>
219      </button>
220      <button class="icon-btn" onclick="toggleTheme()" title="Toggle theme">
221        <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
222          <circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
223        </svg>
224        <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
225          <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
226        </svg>
227      </button>
228      <a href="https://github.com/helgesverre/sql-splitter" class="icon-btn" target="_blank" title="GitHub">
229        <svg viewBox="0 0 24 24" fill="currentColor">
230          <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
231        </svg>
232      </a>
233    </div>
234  </div>
235
236  <script>
237    const mermaidCode = `{mermaid_code_escaped}`;
238    let panzoomInstance = null;
239
240    function getPreferredTheme() {{
241      const saved = localStorage.getItem('erd-theme');
242      if (saved) return saved;
243      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
244    }}
245
246    function setTheme(theme) {{
247      document.body.setAttribute('data-theme', theme);
248      localStorage.setItem('erd-theme', theme);
249      reinitMermaid(theme);
250    }}
251
252    function toggleTheme() {{
253      const current = document.body.getAttribute('data-theme');
254      setTheme(current === 'dark' ? 'light' : 'dark');
255    }}
256
257    function copyMermaid() {{
258      navigator.clipboard.writeText(mermaidCode).then(() => {{
259        const btn = document.getElementById('copyBtn');
260        const txt = document.getElementById('copyText');
261        btn.classList.add('copied');
262        txt.textContent = 'Copied!';
263        setTimeout(() => {{
264          btn.classList.remove('copied');
265          txt.textContent = 'Copy';
266        }}, 2000);
267      }});
268    }}
269
270    function initPanzoom() {{
271      const diagram = document.getElementById('diagram');
272      if (panzoomInstance) panzoomInstance.dispose();
273      panzoomInstance = panzoom(diagram, {{
274        maxZoom: 5,
275        minZoom: 0.1,
276        bounds: false,
277        boundsPadding: 0.1
278      }});
279    }}
280
281    function reinitMermaid(theme) {{
282      mermaid.initialize({{
283        startOnLoad: false,
284        theme: theme === 'dark' ? 'dark' : 'default',
285        maxTextSize: 500000,
286        er: {{ useMaxWidth: false }},
287        securityLevel: 'loose'
288      }});
289      const container = document.getElementById('diagram');
290      container.innerHTML = mermaidCode;
291      container.removeAttribute('data-processed');
292      mermaid.run({{ nodes: [container] }}).then(() => initPanzoom());
293    }}
294
295    document.addEventListener('DOMContentLoaded', () => {{
296      const theme = getPreferredTheme();
297      document.body.setAttribute('data-theme', theme);
298      mermaid.initialize({{
299        startOnLoad: true,
300        theme: theme === 'dark' ? 'dark' : 'default',
301        maxTextSize: 500000,
302        er: {{ useMaxWidth: false }},
303        securityLevel: 'loose'
304      }});
305      mermaid.run().then(() => initPanzoom());
306    }});
307  </script>
308</body>
309</html>"##,
310        title = escape_html(title),
311        mermaid_code = indent_mermaid(&mermaid_code),
312        stats = stats,
313        mermaid_code_escaped = escape_js(&mermaid_code),
314    )
315}
316
317fn escape_html(s: &str) -> String {
318    s.replace('&', "&amp;")
319        .replace('<', "&lt;")
320        .replace('>', "&gt;")
321        .replace('"', "&quot;")
322}
323
324fn escape_js(s: &str) -> String {
325    s.replace('\\', "\\\\")
326        .replace('`', "\\`")
327        .replace("${", "\\${")
328}
329
330fn indent_mermaid(code: &str) -> String {
331    code.lines()
332        .map(|line| format!("      {}", line))
333        .collect::<Vec<_>>()
334        .join("\n")
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::graph::view::{ColumnInfo, TableInfo};
341    use ahash::AHashMap;
342
343    fn create_test_view() -> GraphView {
344        let mut tables = AHashMap::new();
345        tables.insert(
346            "users".to_string(),
347            TableInfo {
348                name: "users".to_string(),
349                columns: vec![ColumnInfo {
350                    name: "id".to_string(),
351                    col_type: "INT".to_string(),
352                    is_primary_key: true,
353                    is_foreign_key: false,
354                    is_nullable: false,
355                    references_table: None,
356                    references_column: None,
357                }],
358            },
359        );
360        GraphView {
361            tables,
362            edges: vec![],
363        }
364    }
365
366    #[test]
367    fn test_html_branding() {
368        let view = create_test_view();
369        let output = to_html(&view, "Test Schema");
370        assert!(output.contains("sql-splitter"));
371        assert!(output.contains("--color-accent: #58a6ff"));
372    }
373
374    #[test]
375    fn test_html_copy_button() {
376        let view = create_test_view();
377        let output = to_html(&view, "Test Schema");
378        assert!(output.contains("copyMermaid()"));
379        assert!(output.contains("Copy Mermaid code"));
380    }
381
382    #[test]
383    fn test_html_contains_mermaid() {
384        let view = create_test_view();
385        let output = to_html(&view, "Test Schema");
386        assert!(output.contains("erDiagram"));
387        assert!(output.contains("maxTextSize: 500000"));
388    }
389
390    #[test]
391    fn test_html_stats() {
392        let view = create_test_view();
393        let output = to_html(&view, "Test Schema");
394        assert!(output.contains("1 tables"));
395        assert!(output.contains("1 columns"));
396    }
397
398    #[test]
399    fn test_html_has_panzoom() {
400        let view = create_test_view();
401        let output = to_html(&view, "Test Schema");
402        assert!(output.contains("panzoom"));
403        assert!(output.contains("initPanzoom"));
404    }
405}