sql_splitter/graph/format/
html.rs1use crate::graph::format::mermaid;
4use crate::graph::view::GraphView;
5
6pub 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('&', "&")
319 .replace('<', "<")
320 .replace('>', ">")
321 .replace('"', """)
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}