|
| 1 | +//! Markdown → Lexical-compatible HTML renderer using comrak. |
| 2 | +//! |
| 3 | +//! Produces HTML that Lexical's `$generateNodesFromDOM` can consume directly, |
| 4 | +//! matching the output of the marked.js pipeline in the frontend. |
| 5 | +
|
| 6 | +use comrak::Arena; |
| 7 | +use comrak::options::{Extension, Options, Parse, Render}; |
| 8 | + |
| 9 | +/// Full pipeline: preprocess → parse → render → postprocess. |
| 10 | +pub fn markdown_to_lexical_html(markdown: &str) -> String { |
| 11 | + let preprocessed = preprocess_blank_lines(markdown); |
| 12 | + |
| 13 | + let arena = Arena::new(); |
| 14 | + let opts = options(); |
| 15 | + let root = comrak::parse_document(&arena, &preprocessed, &opts); |
| 16 | + |
| 17 | + let mut html = String::with_capacity(markdown.len() * 2); |
| 18 | + comrak::format_html(root, &opts, &mut html).expect("HTML rendering failed"); |
| 19 | + |
| 20 | + postprocess_html(&html) |
| 21 | +} |
| 22 | + |
| 23 | +fn options<'a>() -> Options<'a> { |
| 24 | + let mut options = Options::default(); |
| 25 | + |
| 26 | + options.parse = Parse::default(); |
| 27 | + |
| 28 | + options.extension = Extension::default(); |
| 29 | + options.extension.strikethrough = true; |
| 30 | + options.extension.table = true; |
| 31 | + options.extension.tasklist = true; |
| 32 | + options.extension.autolink = true; |
| 33 | + options.extension.highlight = true; |
| 34 | + |
| 35 | + options.render = Render::default(); |
| 36 | + options.render.hardbreaks = true; // Soft breaks → <br> (matches `breaks: true`) |
| 37 | + options.render.r#unsafe = true; // Allow raw HTML passthrough (for <p><br></p> markers) |
| 38 | + options.render.tasklist_classes = true; // Add contains-task-list / task-list-item classes |
| 39 | + |
| 40 | + options |
| 41 | +} |
| 42 | + |
| 43 | +/// Post-process the rendered HTML for Lexical compatibility. |
| 44 | +fn postprocess_html(html: &str) -> String { |
| 45 | + let mut result = html.to_string(); |
| 46 | + |
| 47 | + // Code blocks: rewrite <pre><code class="language-X"> → <pre data-language="X"><code> |
| 48 | + result = regex_lite::Regex::new(r#"<pre><code class="language-([^"]+)">"#) |
| 49 | + .unwrap() |
| 50 | + .replace_all(&result, r#"<pre data-language="$1"><code>"#) |
| 51 | + .to_string(); |
| 52 | + |
| 53 | + // Strikethrough: comrak uses <del>, Lexical expects <s> |
| 54 | + result = result.replace("<del>", "<s>").replace("</del>", "</s>"); |
| 55 | + |
| 56 | + // Comrak 0.51+ adds contains-task-list, task-list-item, and |
| 57 | + // task-list-item-checkbox classes automatically. No post-processing needed. |
| 58 | + |
| 59 | + // Strip whitespace between block-level tags. DOMParser turns newlines between |
| 60 | + // tags (e.g. `</p>\n<pre>`, `</li>\n<li>`) into text nodes that Lexical wraps |
| 61 | + // in phantom empty paragraphs or list items. |
| 62 | + result = regex_lite::Regex::new(r#">\s+<(/?)(p|h[1-6]|ul|ol|li|pre|blockquote|table|thead|tbody|tr|th|td|hr|div|section)"#) |
| 63 | + .unwrap() |
| 64 | + .replace_all(&result, "><$1$2") |
| 65 | + .to_string(); |
| 66 | + |
| 67 | + result |
| 68 | +} |
| 69 | + |
| 70 | +/// Preprocess blank lines into `<p><br></p>` markers, matching the frontend's |
| 71 | +/// `emptyParagraphPreprocess` hook. |
| 72 | +/// |
| 73 | +/// First blank line in a group = standard block separator. |
| 74 | +/// Each additional blank = empty paragraph marker. |
| 75 | +fn preprocess_blank_lines(markdown: &str) -> String { |
| 76 | + let lines: Vec<&str> = markdown.split('\n').collect(); |
| 77 | + let mut result: Vec<&str> = Vec::with_capacity(lines.len()); |
| 78 | + let mut fence_char: Option<u8> = None; |
| 79 | + let mut fence_len: usize = 0; |
| 80 | + let mut i = 0; |
| 81 | + |
| 82 | + while i < lines.len() { |
| 83 | + let line = lines[i]; |
| 84 | + let trimmed = line.trim_start().as_bytes(); |
| 85 | + |
| 86 | + // Track code fence state |
| 87 | + if !trimmed.is_empty() && (trimmed[0] == b'`' || trimmed[0] == b'~') { |
| 88 | + let ch = trimmed[0]; |
| 89 | + let len = trimmed.iter().take_while(|&&b| b == ch).count(); |
| 90 | + if len >= 3 { |
| 91 | + // Skip single-line fenced code (```code```) |
| 92 | + let is_single_line = ch == b'`' && { |
| 93 | + let rest = &line.trim_start()[len..]; |
| 94 | + rest.contains('`') |
| 95 | + && rest.rfind('`').map_or(false, |p| { |
| 96 | + let trailing = rest[p..].bytes().take_while(|&b| b == b'`').count(); |
| 97 | + trailing >= len && p > 0 |
| 98 | + }) |
| 99 | + }; |
| 100 | + if !is_single_line { |
| 101 | + if let Some(fc) = fence_char { |
| 102 | + if ch == fc && len >= fence_len { |
| 103 | + fence_char = None; |
| 104 | + } |
| 105 | + } else { |
| 106 | + fence_char = Some(ch); |
| 107 | + fence_len = len; |
| 108 | + } |
| 109 | + result.push(line); |
| 110 | + i += 1; |
| 111 | + continue; |
| 112 | + } |
| 113 | + } |
| 114 | + } |
| 115 | + |
| 116 | + // Inside code fence — preserve as-is |
| 117 | + if fence_char.is_some() { |
| 118 | + result.push(line); |
| 119 | + i += 1; |
| 120 | + continue; |
| 121 | + } |
| 122 | + |
| 123 | + // Blank line group |
| 124 | + if line.trim().is_empty() { |
| 125 | + let mut blank_count = 0; |
| 126 | + while i < lines.len() && lines[i].trim().is_empty() { |
| 127 | + blank_count += 1; |
| 128 | + i += 1; |
| 129 | + } |
| 130 | + // First blank = standard separator |
| 131 | + result.push(""); |
| 132 | + // Additional blanks = empty paragraphs |
| 133 | + for _ in 1..blank_count { |
| 134 | + result.push("<p><br></p>"); |
| 135 | + result.push(""); |
| 136 | + } |
| 137 | + } else { |
| 138 | + result.push(line); |
| 139 | + i += 1; |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + result.join("\n") |
| 144 | +} |
| 145 | + |
| 146 | +#[cfg(test)] |
| 147 | +mod tests { |
| 148 | + use super::*; |
| 149 | + |
| 150 | + #[test] |
| 151 | + fn test_basic_markdown() { |
| 152 | + let html = markdown_to_lexical_html("# Hello\n\nWorld"); |
| 153 | + assert!(html.contains("<h1>"), "HTML: {html}"); |
| 154 | + assert!(html.contains("Hello"), "HTML: {html}"); |
| 155 | + assert!(html.contains("World"), "HTML: {html}"); |
| 156 | + } |
| 157 | + |
| 158 | + #[test] |
| 159 | + fn test_code_block_language() { |
| 160 | + let html = markdown_to_lexical_html("```rust\nfn main() {}\n```"); |
| 161 | + assert!(html.contains("data-language=\"rust\""), "HTML: {html}"); |
| 162 | + assert!(html.contains("fn main()"), "HTML: {html}"); |
| 163 | + } |
| 164 | + |
| 165 | + #[test] |
| 166 | + fn test_strikethrough() { |
| 167 | + let html = markdown_to_lexical_html("~~deleted~~"); |
| 168 | + assert!(html.contains("<s>"), "HTML: {html}"); |
| 169 | + assert!(html.contains("</s>"), "HTML: {html}"); |
| 170 | + assert!(!html.contains("<del>"), "HTML: {html}"); |
| 171 | + } |
| 172 | + |
| 173 | + #[test] |
| 174 | + fn test_highlight() { |
| 175 | + let html = markdown_to_lexical_html("==highlighted=="); |
| 176 | + assert!(html.contains("<mark>"), "HTML: {html}"); |
| 177 | + assert!(html.contains("</mark>"), "HTML: {html}"); |
| 178 | + } |
| 179 | + |
| 180 | + #[test] |
| 181 | + fn test_empty_paragraphs() { |
| 182 | + let html = markdown_to_lexical_html("hello\n\n\n\nworld"); |
| 183 | + let count = html.matches("<p><br></p>").count(); |
| 184 | + assert_eq!(count, 2, "Expected 2 empty paragraphs, got {count}. HTML: {html}"); |
| 185 | + } |
| 186 | + |
| 187 | + #[test] |
| 188 | + fn test_soft_breaks_become_hard() { |
| 189 | + let html = markdown_to_lexical_html("line1\nline2"); |
| 190 | + assert!(html.contains("<br"), "Soft break should become <br>. HTML: {html}"); |
| 191 | + } |
| 192 | + |
| 193 | + #[test] |
| 194 | + fn test_checklist_classes() { |
| 195 | + let html = markdown_to_lexical_html("- [x] done\n- [ ] todo"); |
| 196 | + assert!(html.contains("contains-task-list"), "UL should have contains-task-list. HTML: {html}"); |
| 197 | + assert!(html.contains("task-list-item"), "LI should have task-list-item. HTML: {html}"); |
| 198 | + } |
| 199 | + |
| 200 | + #[test] |
| 201 | + fn test_checklist_no_phantom_item() { |
| 202 | + let md = "## Action Items\n\n- [x] First\n- [ ] Second\n- [ ] Third"; |
| 203 | + let html = markdown_to_lexical_html(md); |
| 204 | + assert!(!html.contains("<li>\n</li>"), "Unexpected empty list item. HTML: {html}"); |
| 205 | + assert!(!html.contains("<li></li>"), "Unexpected empty list item. HTML: {html}"); |
| 206 | + } |
| 207 | + |
| 208 | + #[test] |
| 209 | + fn test_checklist_with_extra_blank_line() { |
| 210 | + let md = "## Action Items\n\n\n- [x] Sarah to create Jira epics\n- [x] Marcus to schedule design review\n- [ ] David to benchmark latency"; |
| 211 | + let html = markdown_to_lexical_html(md); |
| 212 | + // Should have exactly 3 list items and no empty ones |
| 213 | + assert_eq!(html.matches("<li").count(), 3, "Expected 3 <li> tags. HTML: {html}"); |
| 214 | + assert!(!html.contains("<li>\n</li>"), "Unexpected empty list item. HTML: {html}"); |
| 215 | + assert!(!html.contains("<li></li>"), "Unexpected empty list item. HTML: {html}"); |
| 216 | + } |
| 217 | + |
| 218 | + #[test] |
| 219 | + fn test_image() { |
| 220 | + let html = markdown_to_lexical_html(""); |
| 221 | + assert!(html.contains("<img"), "HTML: {html}"); |
| 222 | + assert!(html.contains("attachment://hash.png"), "HTML: {html}"); |
| 223 | + } |
| 224 | +} |
0 commit comments