From ca76487397c1c8119c63272ccdcb355ba18c349d Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:53:44 +0800 Subject: [PATCH] perf(preview): let render_markdown split its output into top-level blocks With `splitBlocks: true`, `render_markdown` writes `\0` on a line of its own between the top-level blocks of the document, so the preview can diff, process and sanitize block by block instead of the whole document after every typing pause. comrak still parses the whole document, so footnote numbers, reference links and heading-id dedupe are in each block's markup. No separator goes between footnote definitions (one `
`), nor inside a raw HTML element left open across blocks (`
` / markdown / `
`). No document can write the separator: comrak turns NUL into U+FFFD, and the math restore now does the same. Without the flag the output is byte-for-byte what it was; the export and the frontend do not pass it yet. --- src-tauri/src/commands.rs | 17 +++- src-tauri/src/markdown.rs | 175 +++++++++++++++++++++++++++++++++++--- 2 files changed, 177 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3c7e20ab..92dad687 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use crate::fs_safety::{ atomic_write, canonical_identity, encode_text, ensure_path_within_root, read_to_string_lossy, resolve_image_directory, safe_path_component, }; -use crate::markdown::{build_markdown_preview, convert_markdown, outline, Outline}; +use crate::markdown::{build_markdown_preview, convert_markdown_with, outline, Outline}; use crate::window_runtime::{self, WatcherState}; use std::fs; use std::path::Path; @@ -118,9 +118,20 @@ pub async fn markdown_semantic_spans( .await } +/// `split_blocks`: the preview's form, with `BLOCK_SEPARATOR` between +/// top-level blocks. The export asks for the plain document. #[tauri::command] -pub async fn render_markdown(content: String) -> Result { - blocking(move || Ok(convert_markdown(&content))).await +pub async fn render_markdown( + content: String, + split_blocks: Option, +) -> Result { + blocking(move || { + Ok(convert_markdown_with( + &content, + split_blocks.unwrap_or(false), + )) + }) + .await } /// Reads a file, with the fidelity of the decode and the encoding it was diff --git a/src-tauri/src/markdown.rs b/src-tauri/src/markdown.rs index ceca665d..fff3351e 100644 --- a/src-tauri/src/markdown.rs +++ b/src-tauri/src/markdown.rs @@ -1465,6 +1465,7 @@ fn escape_html_text(text: &str) -> String { '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), + '\0' => out.push('\u{FFFD}'), _ => out.push(ch), } } @@ -1480,7 +1481,10 @@ fn escape_html_text(text: &str) -> String { /// /// Not a Tauri command: the registered entry point is `render_markdown`, and /// this is what it calls underneath. -pub(crate) fn convert_markdown(content: &str) -> String { +/// +/// `split_blocks`: write [`BLOCK_SEPARATOR`] between top-level blocks, so the +/// preview can diff, process and sanitize block by block. +pub(crate) fn convert_markdown_with(content: &str, split_blocks: bool) -> String { // The buffer this command was called with, captured before anything runs // and never rebound. What `annotate_task_checkboxes` is handed at the end // has to be this rather than the parameter name: a new step written as a @@ -1496,14 +1500,21 @@ pub(crate) fn convert_markdown(content: &str) -> String { let options = markdown_options(); - let html = render_html(&masked_math.text, &options); + let html = render_html(&masked_math.text, &options, split_blocks); annotate_task_checkboxes(restore_math_spans(&html, &masked_math), raw_buffer) } +/// The document as one string, the form the export and the tests read. +#[cfg(test)] +pub(crate) fn convert_markdown(content: &str) -> String { + convert_markdown_with(content, false) +} + // `hardbreaks` renders a soft break as the same `
` as a hard one. The class // lets the Every Newline Breaks the Line setting hide just these in CSS (#967). The // `
` itself stays: scroll sync and display math read source lines off it. -create_formatter!(PreviewFormatter, { +// The `usize` is the open raw-HTML tag count `block_split_formatter` keeps. +create_formatter!(PreviewFormatter, { NodeValue::SoftBreak => |context, node, entering| { if entering { context.write_str("
String { +/// Between two top-level blocks of the preview's HTML. comrak replaces every +/// NUL in its input with U+FFFD and `escape_html_text` does the same for the +/// math it restores, so no document can write one. +pub(crate) const BLOCK_SEPARATOR: char = '\0'; + +/// Tags a raw HTML block opens and leaves open, minus the ones it closes. +/// `
` / markdown / `
` is three top-level blocks to comrak +/// and one element to the browser, so no separator goes between them. +static HTML_TAG_RE: LazyLock = + LazyLock::new(|| Regex::new(r"<(/?)([A-Za-z][A-Za-z0-9-]*)[^>]*?(/?)>").unwrap()); +const VOID_TAGS: &[&str] = &[ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", + "wbr", +]; + +fn html_depth_change(literal: &str) -> isize { + HTML_TAG_RE + .captures_iter(literal) + .filter(|c| c[3].is_empty() && !VOID_TAGS.contains(&c[2].to_ascii_lowercase().as_str())) + .map(|c| if c[1].is_empty() { 1 } else { -1 }) + .sum() +} + +/// `context.user` is how many tags raw HTML has left open at the top level. +fn block_split_formatter<'a>( + context: &mut comrak::html::Context, + node: &'a comrak::nodes::AstNode<'a>, + entering: bool, +) -> Result { + let top_level = node + .parent() + .is_some_and(|parent| matches!(parent.data().value, NodeValue::Document)); + // Footnote definitions are one block: comrak opens the `
` on the + // first and closes it after the last. + if entering + && top_level + && context.user == 0 + && node.previous_sibling().is_some() + && !(matches!(node.data().value, NodeValue::FootnoteDefinition(_)) + && node + .previous_sibling() + .is_some_and(|prev| matches!(prev.data().value, NodeValue::FootnoteDefinition(_)))) + { + // On a line of its own: the block's own `cr` then has nothing to add. + context.cr()?; + context.write_char(BLOCK_SEPARATOR)?; + context.lf()?; + } + if entering && top_level { + if let NodeValue::HtmlBlock(block) = &node.data().value { + context.user = context + .user + .saturating_add_signed(html_depth_change(&block.literal)); + } + } + PreviewFormatter::formatter(context, node, entering) +} + +fn render_html(markdown: &str, options: &Options, split_blocks: bool) -> String { let arena = Arena::new(); let root = parse_document(&arena, markdown, options); let mut html = String::new(); - PreviewFormatter::format_document(root, options, &mut html) - .expect("writing to a String cannot fail"); + if split_blocks { + comrak::html::format_document_with_formatter( + root, + options, + &mut html, + &comrak::options::Plugins::default(), + block_split_formatter, + 0, + ) + .map(|_| ()) + } else { + PreviewFormatter::format_document(root, options, &mut html, 0).map(|_| ()) + } + .expect("writing to a String cannot fail"); html } @@ -3460,12 +3541,12 @@ pub(crate) mod tests { fn convert_markdown_body() -> String { let source = include_str!("markdown.rs").replace("\r\n", "\n"); let needle = format!( - "\npub(crate) fn {}(content: &str) -> String {{", - "convert_markdown" + "\npub(crate) fn {}(content: &str, split_blocks: bool) -> String {{", + "convert_markdown_with" ); let start = source .find(&needle) - .expect("convert_markdown must keep its `&str -> String` signature"); + .expect("convert_markdown_with must keep its `(&str, bool) -> String` signature"); let rest = &source[start + needle.len()..]; rest[..rest .find("\n}\n") @@ -3533,12 +3614,12 @@ pub(crate) mod tests { // tree happens to store it. let source = include_str!("markdown.rs").replace("\r\n", "\n"); let needle = format!( - "\npub(crate) fn {}(content: &str) -> String {{", - "convert_markdown" + "\npub(crate) fn {}(content: &str, split_blocks: bool) -> String {{", + "convert_markdown_with" ); let start = source .find(&needle) - .expect("convert_markdown must keep its `&str -> String` signature"); + .expect("convert_markdown_with must keep its `(&str, bool) -> String` signature"); let rest = &source[start + needle.len()..]; let body = &rest[..rest .find("\n}\n") @@ -3659,3 +3740,73 @@ pub(crate) mod tests { ); } } + +#[cfg(test)] +mod split_blocks_tests { + use super::{convert_markdown, convert_markdown_with, BLOCK_SEPARATOR}; + + fn blocks(markdown: &str) -> Vec { + convert_markdown_with(markdown, true) + .split(BLOCK_SEPARATOR) + .map(str::to_string) + .collect() + } + + #[test] + fn split_blocks_adds_nothing_but_the_separators() { + for sample in [ + include_str!("../../samples/markdown-syntax.md"), + include_str!("../../samples/markdown-syntax.zh-CN.md"), + include_str!("../../samples/stress-test.md"), + include_str!("../../samples/katex-stress.md"), + include_str!("../../samples/link-test.md"), + ] { + let split = convert_markdown_with(sample, true); + assert!(split.contains(BLOCK_SEPARATOR)); + assert_eq!(split.replace("\0\n", ""), convert_markdown(sample)); + } + } + + #[test] + fn one_separator_between_top_level_blocks() { + let parts = blocks("# A\n\npara\n\n- x\n - nested\n\n> quote\n"); + assert_eq!(parts.len(), 4, "{parts:?}"); + assert!(parts[0].starts_with("")); + } + + #[test] + fn raw_html_left_open_holds_the_blocks_inside_it() { + let parts = blocks("
\ns\n\n**md**\n\n
\n\nafter\n"); + assert_eq!(parts.len(), 2, "{parts:?}"); + assert!(parts[0].contains("")); + } + + #[test] + fn raw_html_that_never_closes_holds_the_rest_of_the_document() { + assert_eq!(blocks("
\n\na\n\nb\n").len(), 1); + } + + #[test] + fn void_and_self_closing_tags_open_nothing() { + assert_eq!(blocks("\n\na\n\n
\n\nb\n").len(), 4); + } + + #[test] + fn no_document_can_write_the_separator() { + let html = convert_markdown_with("a\0b\n\n$x\0y$\n\n`c\0d`\n", true); + assert_eq!(html.matches(BLOCK_SEPARATOR).count(), 2, "{html:?}"); + } +}