pub struct CellExport {
pub name: String,
pub description: Option<String>,
pub source: String,
pub return_type: String,
pub dependencies: Vec<String>,
pub output: Option<String>,
pub error: Option<String>,
pub execution_time_ms: Option<u64>,
}
pub fn generate_html(title: &str, cells: &[CellExport], dark_theme: bool) -> String {
let theme_css = if dark_theme {
DARK_THEME_CSS
} else {
LIGHT_THEME_CSS
};
let mut cells_html = String::new();
for (idx, cell) in cells.iter().enumerate() {
cells_html.push_str(&generate_cell_html(cell, idx + 1));
}
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} - Venus Notebook</title>
<style>
{theme_css}
{HIGHLIGHT_CSS}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1 class="logo">Venus</h1>
<span class="notebook-title">{title}</span>
</header>
<main class="cells">
{cells_html}
</main>
<footer class="footer">
<span>Generated by Venus</span>
</footer>
</div>
<script>
{HIGHLIGHT_JS}
</script>
</body>
</html>"#,
title = html_escape(title),
theme_css = theme_css,
cells_html = cells_html,
)
}
fn generate_cell_html(cell: &CellExport, index: usize) -> String {
let status_class = if cell.error.is_some() {
"error"
} else if cell.output.is_some() {
"success"
} else {
"idle"
};
let deps_html = if cell.dependencies.is_empty() {
String::new()
} else {
let deps: Vec<String> = cell
.dependencies
.iter()
.map(|d| format!(r#"<span class="dep">{}</span>"#, html_escape(d)))
.collect();
format!(r#"<div class="cell-deps">← {}</div>"#, deps.join(", "))
};
let description_html = cell
.description
.as_ref()
.map(|d| format!(r#"<div class="cell-description">{}</div>"#, html_escape(d)))
.unwrap_or_default();
let timing_html = cell
.execution_time_ms
.map(|ms| format!(r#"<span class="cell-timing">{:.1}ms</span>"#, ms as f64))
.unwrap_or_default();
let output_html = if let Some(error) = &cell.error {
format!(
r#"<div class="cell-output error">
<div class="output-header">Error</div>
<pre class="output-content error">{}</pre>
</div>"#,
html_escape(error)
)
} else if let Some(output) = &cell.output {
format!(
r#"<div class="cell-output">
<div class="output-header">Output{}</div>
<pre class="output-content">{}</pre>
</div>"#,
timing_html,
html_escape(output)
)
} else {
String::new()
};
format!(
r#" <div class="cell {status_class}">
<div class="cell-header">
<div class="cell-info">
<span class="cell-index">[{index}]</span>
<span class="cell-name">{name}</span>
<span class="cell-type">→ {return_type}</span>
</div>
{deps_html}
</div>
{description_html}
<div class="cell-source">
<pre><code class="language-rust">{source}</code></pre>
</div>
{output_html}
</div>
"#,
status_class = status_class,
index = index,
name = html_escape(&cell.name),
return_type = html_escape(&cell.return_type),
deps_html = deps_html,
description_html = description_html,
source = html_escape(&cell.source),
output_html = output_html,
)
}
pub fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
const DARK_THEME_CSS: &str = r#"
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-cell: #1c2128;
--border-primary: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent-primary: #7c3aed;
--accent-secondary: #a78bfa;
--success: #3fb950;
--error: #f85149;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary);
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.notebook-title {
color: var(--text-secondary);
font-family: var(--font-mono);
}
.cells { display: flex; flex-direction: column; gap: 1.5rem; }
.cell {
background: var(--bg-cell);
border: 1px solid var(--border-primary);
border-radius: 12px;
overflow: hidden;
}
.cell.success { border-left: 3px solid var(--success); }
.cell.error { border-left: 3px solid var(--error); }
.cell-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
}
.cell-info { display: flex; align-items: center; gap: 0.75rem; }
.cell-index {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-muted);
}
.cell-name {
font-family: var(--font-mono);
font-weight: 600;
color: var(--accent-secondary);
}
.cell-type {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-muted);
}
.cell-deps {
font-size: 0.8rem;
color: var(--text-muted);
}
.dep {
padding: 0.125rem 0.5rem;
background: var(--bg-secondary);
border-radius: 4px;
font-family: var(--font-mono);
margin-left: 0.25rem;
}
.cell-description {
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
color: var(--text-secondary);
font-size: 0.9rem;
}
.cell-source {
padding: 0;
overflow-x: auto;
}
.cell-source pre {
margin: 0;
padding: 1rem;
background: var(--bg-cell);
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.5;
}
.cell-source code {
font-family: inherit;
}
.cell-output {
border-top: 1px solid var(--border-primary);
background: var(--bg-secondary);
}
.output-header {
display: flex;
justify-content: space-between;
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.output-content {
padding: 1rem;
font-family: var(--font-mono);
font-size: 0.875rem;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
background: transparent;
}
.output-content.error {
color: var(--error);
background: rgba(248, 81, 73, 0.1);
}
.cell-timing {
font-family: var(--font-mono);
}
.footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
}
"#;
const LIGHT_THEME_CSS: &str = r#"
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #f0f2f5;
--bg-cell: #ffffff;
--border-primary: #d0d7de;
--text-primary: #1f2328;
--text-secondary: #656d76;
--text-muted: #8c959f;
--accent-primary: #7c3aed;
--accent-secondary: #8b5cf6;
--success: #1a7f37;
--error: #cf222e;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary);
}
.logo {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.notebook-title {
color: var(--text-secondary);
font-family: var(--font-mono);
}
.cells { display: flex; flex-direction: column; gap: 1.5rem; }
.cell {
background: var(--bg-cell);
border: 1px solid var(--border-primary);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.cell.success { border-left: 3px solid var(--success); }
.cell.error { border-left: 3px solid var(--error); }
.cell-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
}
.cell-info { display: flex; align-items: center; gap: 0.75rem; }
.cell-index {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-muted);
}
.cell-name {
font-family: var(--font-mono);
font-weight: 600;
color: var(--accent-primary);
}
.cell-type {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-muted);
}
.cell-deps {
font-size: 0.8rem;
color: var(--text-muted);
}
.dep {
padding: 0.125rem 0.5rem;
background: var(--bg-secondary);
border-radius: 4px;
font-family: var(--font-mono);
margin-left: 0.25rem;
}
.cell-description {
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
color: var(--text-secondary);
font-size: 0.9rem;
}
.cell-source {
padding: 0;
overflow-x: auto;
}
.cell-source pre {
margin: 0;
padding: 1rem;
background: var(--bg-secondary);
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.5;
}
.cell-source code {
font-family: inherit;
}
.cell-output {
border-top: 1px solid var(--border-primary);
background: var(--bg-secondary);
}
.output-header {
display: flex;
justify-content: space-between;
padding: 0.5rem 1rem;
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.output-content {
padding: 1rem;
font-family: var(--font-mono);
font-size: 0.875rem;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
background: transparent;
}
.output-content.error {
color: var(--error);
background: rgba(207, 34, 46, 0.05);
}
.cell-timing {
font-family: var(--font-mono);
}
.footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-primary);
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
}
"#;
const HIGHLIGHT_CSS: &str = r#"
/* Rust syntax highlighting */
.keyword { color: #ff7b72; }
.string { color: #a5d6ff; }
.number { color: #79c0ff; }
.comment { color: #8b949e; font-style: italic; }
.function { color: #d2a8ff; }
.type { color: #7ee787; }
.macro { color: #ffa657; }
.attribute { color: #79c0ff; }
.operator { color: #ff7b72; }
.punctuation { color: #8b949e; }
"#;
const HIGHLIGHT_JS: &str = r#"
document.querySelectorAll('code.language-rust').forEach(function(block) {
var html = block.innerHTML;
// Keywords
html = html.replace(/\b(fn|let|mut|const|pub|use|mod|struct|enum|impl|trait|type|where|for|loop|while|if|else|match|return|break|continue|move|ref|self|Self|super|crate|as|in|dyn|async|await|unsafe|extern)\b/g, '<span class="keyword">$1</span>');
// Types
html = html.replace(/\b(i8|i16|i32|i64|i128|isize|u8|u16|u32|u64|u128|usize|f32|f64|bool|char|str|String|Vec|Option|Result|Box|Rc|Arc|Cell|RefCell|HashMap|HashSet|BTreeMap|BTreeSet)\b/g, '<span class="type">$1</span>');
// Strings
html = html.replace(/(\"[^\"]*\")/g, '<span class="string">$1</span>');
// Numbers
html = html.replace(/\b(\d+\.?\d*(?:e[+-]?\d+)?(?:_\d+)*(?:i8|i16|i32|i64|i128|isize|u8|u16|u32|u64|u128|usize|f32|f64)?)\b/g, '<span class="number">$1</span>');
// Comments
html = html.replace(/(\/\/.*$)/gm, '<span class="comment">$1</span>');
// Macros
html = html.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*!)/g, '<span class="macro">$1</span>');
// Attributes
html = html.replace(/(#\[[^\]]+\])/g, '<span class="attribute">$1</span>');
block.innerHTML = html;
});
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_html_escape() {
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("a & b"), "a & b");
assert_eq!(html_escape("\"quoted\""), ""quoted"");
}
#[test]
fn test_generate_html() {
let cells = vec![CellExport {
name: "test".to_string(),
description: Some("A test cell".to_string()),
source: "fn test() -> i32 { 42 }".to_string(),
return_type: "i32".to_string(),
dependencies: vec![],
output: Some("42".to_string()),
error: None,
execution_time_ms: Some(10),
}];
let html = generate_html("Test", &cells, true);
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Test - Venus Notebook"));
assert!(html.contains("test"));
assert!(html.contains("42"));
}
}