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:?}"); + } +}