use crate::core::mermaid::process_mermaid_blocks;
use crate::core::slug::SlugGenerator;
use comrak::{markdown_to_html, Options};
pub fn parse_markdown(content: &str) -> String {
let mut options = Options::default();
options.extension.strikethrough = true;
options.extension.table = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
options.extension.front_matter_delimiter = Some("---".to_string());
options.render.r#unsafe = true;
let html = markdown_to_html(content, &options);
let html = add_heading_ids(&html);
process_mermaid_blocks(&html)
}
fn add_heading_ids(html: &str) -> String {
use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| regex::Regex::new(r"<(h[1-6])>(.*?)</h[1-6]>").unwrap());
let mut slugs = SlugGenerator::new();
re.replace_all(html, |caps: ®ex::Captures| {
let tag = &caps[1];
let content = &caps[2];
let plain_text = strip_html_tags(content);
let id = slugs.generate(&plain_text);
format!("<{} id=\"{}\">{}</{}>", tag, id, content, tag)
})
.to_string()
}
fn strip_html_tags(html: &str) -> String {
use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| regex::Regex::new(r"<[^>]+>").unwrap());
re.replace_all(html, "").to_string()
}
pub const GITHUB_CSS: &str = r#"
@media (prefers-color-scheme: dark) {
:root { --bg: #0d1117; --fg: #e6edf3; --code-bg: #161b22; --border: #30363d; --link: #58a6ff; --blockquote: #8b949e; --sidebar-bg: #010409; --sidebar-hover: #161b22; --sidebar-active: #1f6feb33; }
}
@media (prefers-color-scheme: light) {
:root { --bg: #ffffff; --fg: #1f2328; --code-bg: #f6f8fa; --border: #d0d7de; --link: #0969da; --blockquote: #656d76; --sidebar-bg: #f6f8fa; --sidebar-hover: #eaeef2; --sidebar-active: #ddf4ff; }
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
color: var(--fg);
background: var(--bg);
display: flex;
}
.sidebar {
width: 250px;
min-width: 250px;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 16px 0;
font-size: 14px;
}
.sidebar-title {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--blockquote);
padding: 8px 16px;
margin: 0;
}
.sidebar ul { list-style: none; margin: 0; padding: 0; }
.sidebar li a {
display: block;
padding: 4px 16px;
color: var(--fg);
text-decoration: none;
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.sidebar li a:hover { background: var(--sidebar-hover); }
.sidebar li a.active { background: var(--sidebar-active); border-left-color: var(--link); color: var(--link); }
.sidebar li.toc-h2 a { padding-left: 24px; }
.sidebar li.toc-h3 a { padding-left: 36px; font-size: 13px; }
.sidebar li.toc-h4 a { padding-left: 48px; font-size: 13px; color: var(--blockquote); }
.sidebar li.toc-h5 a, .sidebar li.toc-h6 a { padding-left: 56px; font-size: 12px; color: var(--blockquote); }
.content {
margin-left: 250px;
max-width: 900px;
padding: 32px 24px 3rem;
flex: 1;
}
h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; line-height: 1.25; }
h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); }
h2 { font-size: 1.5em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border); }
code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 85%;
background: var(--code-bg);
padding: 0.2em 0.4em;
border-radius: 6px;
}
pre {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
background: var(--code-bg);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
line-height: 1.45;
}
pre code { background: transparent; padding: 0; font-size: 85%; }
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
th, td { border: 1px solid var(--border); padding: 6px 13px; }
th { font-weight: 600; background: var(--code-bg); }
blockquote {
color: var(--blockquote);
border-left: 4px solid var(--border);
padding: 0 16px;
margin: 16px 0;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
img { max-width: 100%; }
ul, ol { padding-left: 2em; }
input[type="checkbox"] { margin-right: 0.5em; }
.mermaid-diagram { text-align: center; margin: 16px 0; }
.mermaid-diagram svg { max-width: 100%; height: auto; }
.mermaid-error {
border: 2px solid #f85149;
border-radius: 6px;
padding: 16px;
margin: 16px 0;
background: var(--code-bg);
}
.mermaid-error strong { color: #f85149; }
.mermaid-fallback {
border: 1px solid var(--border);
border-radius: 6px;
margin: 16px 0;
background: var(--code-bg);
overflow: hidden;
}
.mermaid-fallback-header {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
color: var(--blockquote);
border-bottom: 1px solid var(--border);
background: var(--sidebar-bg);
}
.mermaid-icon { margin-right: 6px; }
.mermaid-fallback pre { margin: 0; border-radius: 0; }
.mermaid-fallback code { font-size: 13px; color: var(--fg); }
/* Search */
.search-bar {
position: fixed;
bottom: 0;
left: 250px;
right: 0;
background: var(--code-bg);
border-top: 1px solid var(--border);
padding: 8px 16px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
font-size: 14px;
}
.search-bar input {
flex: 1;
max-width: 400px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--fg);
font-size: 14px;
outline: none;
}
.search-bar input:focus { border-color: var(--link); }
.search-bar .search-info { color: var(--blockquote); white-space: nowrap; }
.search-bar button {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--code-bg);
color: var(--fg);
cursor: pointer;
font-size: 13px;
}
.search-bar button:hover { background: var(--sidebar-hover); }
.search-bar .close-btn { margin-left: auto; }
mark.search-highlight { background: #ffd33d55; color: inherit; border-radius: 2px; }
mark.search-highlight.current { background: #ffd33d; color: #000; }
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heading_ids_added_to_h1() {
let html = "<h1>Hello World</h1>";
let result = add_heading_ids(html);
assert!(result.contains(r#"<h1 id="hello-world">Hello World</h1>"#));
}
#[test]
fn heading_ids_added_to_multiple_levels() {
let html = "<h1>Title</h1><h2>Section</h2><h3>Sub</h3>";
let result = add_heading_ids(html);
assert!(result.contains(r#"<h1 id="title">"#));
assert!(result.contains(r#"<h2 id="section">"#));
assert!(result.contains(r#"<h3 id="sub">"#));
}
#[test]
fn heading_ids_strip_inner_html_tags() {
let html = "<h2>Hello <code>world</code></h2>";
let result = add_heading_ids(html);
assert!(result.contains(r#"id="hello-world""#));
assert!(result.contains("<code>world</code>"));
}
#[test]
fn heading_ids_no_headings_unchanged() {
let html = "<p>Just a paragraph</p>";
let result = add_heading_ids(html);
assert_eq!(result, html);
}
#[test]
fn strip_html_tags_removes_tags() {
assert_eq!(strip_html_tags("<b>bold</b>"), "bold");
assert_eq!(strip_html_tags("no tags"), "no tags");
assert_eq!(strip_html_tags("<a href=\"#\">link</a>"), "link");
}
#[test]
fn parse_markdown_basic_paragraph() {
let result = parse_markdown("Hello world");
assert!(result.contains("Hello world"));
assert!(result.contains("<p>"));
}
#[test]
fn parse_markdown_heading_gets_id() {
let result = parse_markdown("# My Title");
assert!(result.contains(r#"id="my-title""#));
assert!(result.contains("My Title"));
}
#[test]
fn parse_markdown_multiple_headings_get_ids() {
let result = parse_markdown("# First\n## Second\n### Third");
assert!(result.contains(r#"id="first""#));
assert!(result.contains(r#"id="second""#));
assert!(result.contains(r#"id="third""#));
}
#[test]
fn duplicate_headings_get_distinct_ids() {
let result = parse_markdown("## Setup\n\ntext\n\n## Setup\n\nmore");
assert!(result.contains(r#"id="setup""#), "{}", result);
assert!(result.contains(r#"id="setup-1""#), "{}", result);
}
#[test]
fn yaml_front_matter_is_not_rendered_as_content() {
let md = "---\ntitle: front matter\ntags: [a, b]\n---\n\n# Title\n\nText.\n";
let result = parse_markdown(md);
assert!(!result.contains("title: front matter"), "{}", result);
assert!(!result.contains("tags:"), "{}", result);
assert!(result.contains("Title"), "{}", result);
}
#[test]
fn parse_markdown_table() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let result = parse_markdown(md);
assert!(result.contains("<table>"));
assert!(result.contains("<th>"));
assert!(result.contains("<td>"));
}
#[test]
fn parse_markdown_tasklist() {
let md = "- [x] Done\n- [ ] Todo";
let result = parse_markdown(md);
assert!(result.contains("checkbox"));
}
#[test]
fn parse_markdown_strikethrough() {
let md = "This is ~~deleted~~ text.";
let result = parse_markdown(md);
assert!(result.contains("<del>"));
assert!(result.contains("deleted"));
}
#[test]
fn parse_markdown_mermaid_block_is_processed() {
let md = "```mermaid\ngraph LR\n A-->B\n```";
let result = parse_markdown(md);
assert!(
result.contains("mermaid-diagram")
|| result.contains("mermaid-error")
|| result.contains("mermaid-fallback"),
"Mermaid block should be processed, got: {}",
result
);
}
#[test]
fn parse_markdown_empty_input() {
let result = parse_markdown("");
assert!(result.is_empty() || result.trim().is_empty());
}
#[test]
fn parse_markdown_code_block_not_mermaid() {
let md = "```rust\nfn main() {}\n```";
let result = parse_markdown(md);
assert!(result.contains("<code"));
assert!(!result.contains("mermaid-diagram"));
}
#[test]
fn parse_markdown_raw_html_img_preserved() {
let md = r#"<img src="chart.png" alt="Revenue chart" width="600" />"#;
let result = parse_markdown(md);
assert!(
result.contains("<img"),
"Raw HTML <img> tags should be preserved, got: {}",
result
);
assert!(
result.contains("chart.png"),
"Image src should be preserved, got: {}",
result
);
}
#[test]
fn parse_markdown_raw_html_img_with_attributes() {
let md = r#"<p align="center"><img src="logo.png" alt="logo" width="200"/></p>"#;
let result = parse_markdown(md);
assert!(
result.contains("<img"),
"Centered HTML image should be preserved, got: {}",
result
);
assert!(
result.contains("logo.png"),
"Image src should be preserved, got: {}",
result
);
}
#[test]
fn parse_markdown_markdown_image_syntax_works() {
let md = "";
let result = parse_markdown(md);
assert!(
result.contains("<img"),
"Markdown image should produce <img>, got: {}",
result
);
assert!(
result.contains("image.png"),
"Image src should be present, got: {}",
result
);
}
}