Skip to main content

hematite/agent/
html_template.rs

1use std::fmt::Write as _;
2
3/// Shared dark-theme HTML shell for all Hematite HTML outputs.
4/// Any feature that saves an HTML file calls `build_html_shell` — consistent
5/// look everywhere, CSS and JS defined in one place.
6pub fn he(s: &str) -> String {
7    let mut out: Option<String> = None;
8    let mut last = 0usize;
9    for (i, b) in s.bytes().enumerate() {
10        let esc = match b {
11            b'&' => "&amp;",
12            b'<' => "&lt;",
13            b'>' => "&gt;",
14            b'"' => "&#34;",
15            _ => continue,
16        };
17        let buf = out.get_or_insert_with(|| String::with_capacity(s.len() + 8));
18        buf.push_str(&s[last..i]);
19        buf.push_str(esc);
20        last = i + 1;
21    }
22    match out {
23        None => s.to_string(),
24        Some(mut buf) => {
25            buf.push_str(&s[last..]);
26            buf
27        }
28    }
29}
30
31const CSS: &str = r#":root{--bg:#000;--fg:#fff;--dim:#6b6b6b;--line:#1a1a1a;--line-2:#262626}
32*{box-sizing:border-box;margin:0;padding:0}
33html{scrollbar-width:thin;scrollbar-color:#2a2a2a #000}
34::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:#000}::-webkit-scrollbar-thumb{background:#222;border-radius:999px;border:2px solid #000}::-webkit-scrollbar-thumb:hover{background:#333}
35body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background:var(--bg);color:var(--fg);padding:2.5rem 1.5rem;min-height:100vh}
36.wrap{max-width:900px;margin:0 auto}
37header{background:#0a0a0a;border:1px solid var(--line-2);border-radius:18px;padding:2rem 2.25rem;margin-bottom:1rem}
38h1{font-size:1.35rem;font-weight:600;letter-spacing:-0.025em;color:var(--fg);margin-bottom:.6rem}
39.meta{font-size:.775rem;color:var(--dim);margin-bottom:1.25rem;display:flex;flex-wrap:wrap;gap:.4rem 1.5rem;letter-spacing:-0.005em}
40.score-row{display:flex;align-items:center;gap:1rem;flex-wrap:wrap}
41.grade{font-size:2rem;font-weight:800;width:3rem;height:3rem;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0;letter-spacing:-0.02em}
42.gA{background:#14532d;color:#4ade80}.gB{background:#166534;color:#86efac}.gC{background:#78350f;color:#fbbf24}.gD{background:#7c2d12;color:#fb923c}.gF{background:#7f1d1d;color:#f87171}
43.score-info h2{font-size:1rem;font-weight:600;letter-spacing:-0.02em;color:var(--fg)}.score-info p{color:#a3a3a3;font-size:.85rem;margin-top:.2rem;letter-spacing:-0.005em}
44section{background:#0a0a0a;border:1px solid var(--line-2);border-radius:18px;padding:2rem 2.25rem;margin-bottom:1rem}
45section>h2{font-size:.85rem;font-weight:600;letter-spacing:-0.01em;color:#d4d4d4;margin-bottom:1.25rem;padding-bottom:.75rem;border-bottom:1px solid var(--line)}
46.recipe{padding:1rem 1.25rem;border-left:3px solid var(--line-2);border-radius:0 10px 10px 0;margin-bottom:.75rem;background:#111}
47.recipe:last-child{margin-bottom:0}
48.sev-action{border-left-color:#dc2626}.sev-investigate{border-left-color:#d97706}.sev-monitor{border-left-color:#3b82f6}
49.recipe h3{font-size:.875rem;font-weight:600;letter-spacing:-0.015em;margin-bottom:.7rem;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;color:var(--fg)}
50.badge{font-size:.65rem;font-weight:700;padding:.2rem .5rem;border-radius:5px;letter-spacing:.02em}
51.b-action{background:#7f1d1d;color:#f87171}.b-investigate{background:#78350f;color:#fbbf24}.b-monitor{background:#1e3a5f;color:#93c5fd}
52.recipe ol{padding-left:1.2rem;color:#d4d4d4}
53.recipe li{margin-bottom:.4rem;line-height:1.6;font-size:.85rem}
54.dig-deeper{font-size:.75rem;color:#4b4b4b;margin-top:.7rem}
55.dig-deeper code{background:var(--line);padding:.1rem .3rem;border-radius:3px;font-size:.75rem;color:#6b6b6b}
56.healthy{color:#4ade80;font-weight:500;font-size:.875rem;padding:.4rem 0;letter-spacing:-0.01em}
57details{border:1px solid var(--line);border-radius:10px;margin-bottom:.6rem;overflow:hidden}
58details:last-child{margin-bottom:0}
59summary{cursor:pointer;font-weight:500;font-size:.8rem;color:#a3a3a3;padding:.7rem 1rem;background:#111;list-style:none;user-select:none;letter-spacing:-0.005em;transition:color 150ms ease,background 150ms ease}
60summary::-webkit-details-marker{display:none}
61summary::before{content:'▶  ';font-size:.6rem;color:var(--dim)}
62details[open] summary::before{content:'▼  '}
63summary:hover{background:#161616;color:var(--fg)}
64pre{font-family:'Cascadia Code','JetBrains Mono','Fira Code',Consolas,monospace;font-size:.75rem;background:#000;color:#a3a3a3;padding:1.25rem;overflow-x:auto;white-space:pre-wrap;word-break:break-word;line-height:1.6;margin:0;border-top:1px solid var(--line)}
65footer{text-align:center;color:var(--dim);font-size:.725rem;margin-top:1.5rem;padding-top:1rem;letter-spacing:-0.005em}
66@media(max-width:640px){body{padding:1.5rem .75rem}header,section{padding:1.5rem;border-radius:14px}}
67.copy-btn{display:inline-flex;align-items:center;gap:8px;margin-top:1.25rem;padding:9px 18px;border-radius:999px;font-family:inherit;font-size:.8rem;font-weight:500;letter-spacing:-0.005em;cursor:pointer;background:transparent;color:#d4d4d4;border:1px solid var(--line-2);transition:border-color 160ms ease,color 160ms ease,background 160ms ease}
68.copy-btn:hover{border-color:var(--fg);color:var(--fg)}
69.copy-btn.copied{border-color:#4ade80;color:#4ade80}
70p{line-height:1.6;color:#d4d4d4;font-size:.9rem;letter-spacing:-0.005em}
71p+p{margin-top:.75rem}
72h2{font-size:1.1rem;font-weight:600;letter-spacing:-0.02em;margin-bottom:.75rem}
73h3{font-size:.95rem;font-weight:600;letter-spacing:-0.015em;margin-bottom:.5rem}
74ul,ol{padding-left:1.25rem;color:#d4d4d4}
75li{margin-bottom:.4rem;line-height:1.6;font-size:.875rem}
76a{color:#d4d4d4;text-decoration:none;border-bottom:1px solid var(--line-2);transition:border-color 150ms ease,color 150ms ease}
77a:hover{color:var(--fg);border-bottom-color:var(--fg)}
78.grade-intro{font-size:.9rem;color:#d4d4d4;margin-top:.85rem;line-height:1.55;letter-spacing:-0.005em}"#;
79
80// DOM-driven — reads title, score, recipes, and sections from the page.
81// Works for any content built with the shared CSS classes; no format args needed.
82const COPY_SCRIPT: &str = r#"
83function copyReport() {
84  var btn = document.getElementById('copyBtn');
85  if (!btn) return;
86  var orig = btn.innerHTML;
87  var lines = [];
88  var h1 = document.querySelector('h1'); if (h1) lines.push(h1.innerText);
89  var sh2 = document.querySelector('.score-info h2'); if (sh2) lines.push(sh2.innerText);
90  var sp = document.querySelector('.score-info p'); if (sp) { lines.push(sp.innerText); lines.push(''); }
91  document.querySelectorAll('.recipe').forEach(function(r) {
92    var h = r.querySelector('h3'); if (h) lines.push(h.innerText);
93    r.querySelectorAll('li').forEach(function(li) { lines.push('- ' + li.innerText); });
94    lines.push('');
95  });
96  var dets = document.querySelectorAll('details');
97  if (dets.length) {
98    lines.push('--- Diagnostic Data ---');
99    dets.forEach(function(d) {
100      var s = d.querySelector('summary'); if (s) lines.push('\n[' + s.innerText.trim() + ']');
101      var pre = d.querySelector('pre'); if (pre) lines.push(pre.textContent.trim());
102    });
103  } else {
104    document.querySelectorAll('section').forEach(function(sec) {
105      var sh = sec.querySelector('h2'); if (sh) lines.push('\n--- ' + sh.innerText + ' ---');
106      lines.push(sec.innerText.replace(sh ? sh.innerText : '', '').trim());
107    });
108  }
109  navigator.clipboard.writeText(lines.join('\n')).then(function() {
110    btn.textContent = 'Copied!';
111    btn.classList.add('copied');
112    setTimeout(function() { btn.innerHTML = orig; btn.classList.remove('copied'); }, 2000);
113  });
114}
115"#;
116
117pub const COPY_BUTTON_HTML: &str = r#"<button class="copy-btn" id="copyBtn" onclick="copyReport()">
118  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
119  Copy report for AI
120</button>"#;
121
122/// Convert a subset of Markdown to HTML suitable for research/note pages.
123/// Handles: ATX headings, fenced code blocks, inline code, bold, bullet lists,
124/// blank-line-separated paragraphs, and bare URLs as links.
125pub fn markdown_to_html(md: &str) -> String {
126    let mut out = String::with_capacity(md.len());
127    let mut in_code_block = false;
128    let mut code_buf = String::with_capacity(256);
129    let mut list_items: Vec<String> = Vec::with_capacity(8);
130
131    let flush_list = |items: &mut Vec<String>, out: &mut String| {
132        if !items.is_empty() {
133            out.push_str("<ul>\n");
134            for item in items.iter() {
135                let _ = writeln!(out, "<li>{}</li>", item);
136            }
137            out.push_str("</ul>\n");
138            items.clear();
139        }
140    };
141
142    for line in md.lines() {
143        // fenced code block toggle
144        if line.starts_with("```") {
145            if in_code_block {
146                let _ = writeln!(out, "<pre>{}</pre>", he(code_buf.trim_end()));
147                code_buf.clear();
148                in_code_block = false;
149            } else {
150                flush_list(&mut list_items, &mut out);
151                in_code_block = true;
152            }
153            continue;
154        }
155        if in_code_block {
156            code_buf.push_str(line);
157            code_buf.push('\n');
158            continue;
159        }
160
161        // headings
162        if let Some(rest) = line.strip_prefix("### ") {
163            flush_list(&mut list_items, &mut out);
164            let _ = writeln!(out, "<h3>{}</h3>", inline_md(rest));
165            continue;
166        }
167        if let Some(rest) = line.strip_prefix("## ") {
168            flush_list(&mut list_items, &mut out);
169            let _ = writeln!(out, "<h2>{}</h2>", inline_md(rest));
170            continue;
171        }
172        if let Some(rest) = line.strip_prefix("# ") {
173            flush_list(&mut list_items, &mut out);
174            let _ = writeln!(out, "<h2>{}</h2>", inline_md(rest));
175            continue;
176        }
177
178        // bullet list items
179        if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
180            list_items.push(inline_md(rest));
181            continue;
182        }
183        if let Some(rest) = line
184            .strip_prefix("  - ")
185            .or_else(|| line.strip_prefix("  * "))
186        {
187            list_items.push(inline_md(rest));
188            continue;
189        }
190
191        // blank line — close any open list, emit paragraph break
192        if line.trim().is_empty() {
193            flush_list(&mut list_items, &mut out);
194            out.push('\n');
195            continue;
196        }
197
198        // paragraph line
199        flush_list(&mut list_items, &mut out);
200        let _ = writeln!(out, "<p>{}</p>", inline_md(line));
201    }
202
203    flush_list(&mut list_items, &mut out);
204    if in_code_block && !code_buf.is_empty() {
205        let _ = writeln!(out, "<pre>{}</pre>", he(code_buf.trim_end()));
206    }
207
208    // Collapse consecutive <p> tags separated only by blank lines into a section
209    out
210}
211
212/// Process inline markdown: bold, inline code, bare URLs.
213fn inline_md(s: &str) -> String {
214    let mut result = he(s);
215    // bold **text**
216    result = replace_pairs(&result, "**", "<strong>", "</strong>");
217    // bold __text__
218    result = replace_pairs(&result, "__", "<strong>", "</strong>");
219    // italic *text* (single star, not preceded by another star)
220    result = replace_pairs(&result, "*", "<em>", "</em>");
221    // inline code `text`
222    result = replace_pairs(&result, "`", "<code>", "</code>");
223    // bare URLs
224    result = linkify(&result);
225    result
226}
227
228fn replace_pairs(s: &str, delim: &str, open: &str, close: &str) -> String {
229    let mut out = String::with_capacity(s.len());
230    let mut rest = s;
231    let mut open_tag = true;
232    while let Some(pos) = rest.find(delim) {
233        out.push_str(&rest[..pos]);
234        out.push_str(if open_tag { open } else { close });
235        rest = &rest[pos + delim.len()..];
236        open_tag = !open_tag;
237    }
238    out.push_str(rest);
239    out
240}
241
242fn linkify(s: &str) -> String {
243    // Simple pass: wrap bare http/https URLs not already inside an href
244    let mut out = String::with_capacity(s.len());
245    let mut rest = s;
246    while let Some(pos) = rest.find("http") {
247        let pre = &rest[..pos];
248        // Skip if already inside an href
249        if pre.ends_with("href=\"") || pre.ends_with("href='") {
250            out.push_str(&rest[..pos + 4]);
251            rest = &rest[pos + 4..];
252            continue;
253        }
254        out.push_str(pre);
255        let url_start = &rest[pos..];
256        let end = url_start
257            .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"' || c == '\'')
258            .unwrap_or(url_start.len());
259        let url = &url_start[..end];
260        let escaped = he(url);
261        let _ = write!(
262            out,
263            "<a href=\"{}\" target=\"_blank\">{}</a>",
264            escaped, escaped
265        );
266        rest = &url_start[end..];
267    }
268    out.push_str(rest);
269    out
270}
271
272/// Wrap `content_html` in the Hematite dark-theme shell.
273/// `content_html` is everything inside `.wrap` — assemble header cards,
274/// sections, etc. in the caller. The shell provides CSS, JS, and the footer.
275pub fn build_html_shell(title: &str, version: &str, content_html: &str) -> String {
276    format!(
277        r#"<!DOCTYPE html>
278<html lang="en">
279<head>
280<meta charset="utf-8">
281<meta name="viewport" content="width=device-width,initial-scale=1">
282<title>{title}</title>
283<style>{css}</style>
284</head>
285<body>
286<div class="wrap">
287{content}
288</div>
289<footer>Generated by Hematite v{version}</footer>
290<script>{script}</script>
291</body>
292</html>"#,
293        title = he(title),
294        version = he(version),
295        css = CSS,
296        content = content_html,
297        script = COPY_SCRIPT,
298    )
299}