Skip to content

Commit c3c3090

Browse files
Improve markdown loading and round-trip fidelity
1 parent 767a0e5 commit c3c3090

18 files changed

Lines changed: 581 additions & 52 deletions

src-tauri/Cargo.lock

Lines changed: 51 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ base64 = "0.22.1"
4040
regex-lite = "0.1.9"
4141
chacha20 = "0.9"
4242
chacha20poly1305 = "0.10.1"
43+
comrak = { version = "0.51", default-features = false }
4344
subtle = "2"
4445
getrandom = "0.3"
4546
hex = "0.4.3"

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mod attachments;
22
mod blossom;
33
mod db;
44
mod error;
5+
mod markdown;
56
mod nip44_ext;
67
mod nip59_ext;
78
mod nostr;

src-tauri/src/markdown.rs

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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("![alt](attachment://hash.png)");
221+
assert!(html.contains("<img"), "HTML: {html}");
222+
assert!(html.contains("attachment://hash.png"), "HTML: {html}");
223+
}
224+
}

src-tauri/src/notes.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub struct LoadedNote {
5555
pub notebook: Option<NotebookRef>,
5656
pub modified_at: i64,
5757
pub markdown: String,
58+
pub html: String,
5859
pub archived_at: Option<i64>,
5960
pub deleted_at: Option<i64>,
6061
pub pinned_at: Option<i64>,
@@ -1128,11 +1129,14 @@ fn set_last_open_note_id(conn: &Connection, note_id: Option<&str>) -> Result<(),
11281129
fn row_to_loaded_note(row: &rusqlite::Row<'_>) -> rusqlite::Result<LoadedNote> {
11291130
let notebook_id: Option<String> = row.get(4)?;
11301131
let notebook_name: Option<String> = row.get(5)?;
1132+
let markdown: String = row.get(2)?;
1133+
let html = crate::markdown::markdown_to_lexical_html(&markdown);
11311134

11321135
Ok(LoadedNote {
11331136
id: row.get(0)?,
11341137
title: row.get(1)?,
1135-
markdown: row.get(2)?,
1138+
markdown,
1139+
html,
11361140
modified_at: row.get(3)?,
11371141
notebook: notebook_id
11381142
.zip(notebook_name)

0 commit comments

Comments
 (0)