diff --git a/CHANGELOG.md b/CHANGELOG.md index a40405f..7b8b9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [Unreleased] + +- Breaking: Markdown output (`to_markdown`, `chunks_by_heading`, `chunks_by_size`, `truncate_markdown`) uses pulldown-cmark-to-cmark 23; escaping and formatting differ from 0.2. +- Fix raw HTML leaking into Markdown output with `raw_html: false` when a line held more than one tag. +- Fix `chunks_by_size` and `truncate_markdown` (`at: :block`) joining blocks without a blank line, which merged adjacent paragraphs. +- Fix Markdown output crashing on an image nested in another image's alt text with `images: { lazy: true }`, `allowed_hosts`, or `allowed_schemes`. +- `chunks_by_heading` starts sections only at top-level headings; a heading inside a block quote or list item no longer splits it. +- Raise `Inkmark::Error` on Markdown serialization failures instead of crashing the process. + ## [0.2.2] - 2026-10-24 - Updated dependencies, including pulldown-cmark 0.13.5 (upstream security fixes) and magnus 0.9.3. diff --git a/Cargo.lock b/Cargo.lock index ae7c653..97a816e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -527,11 +527,12 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "pulldown-cmark-to-cmark" -version = "22.0.3" +version = "23.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" +checksum = "e9e14cd4ec725b2227ba76d4772169af814438bcf48f6d9b806d1308f40a7413" dependencies = [ "pulldown-cmark", + "unicase", ] [[package]] diff --git a/README.md b/README.md index 69ecb1a..a9e11de 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,9 @@ Inkmark.to_html("") # => "

<script>alert(1)</script>

\n" ``` +Markdown output escapes it too; see +[Raw HTML in the source](#raw-html-in-the-source). + Enable pass-through with `raw_html: true`; _only do this for trusted input_: @@ -403,6 +406,12 @@ nested `###` subsections, which also appear as their own entries. Content before the first heading (if any) becomes a preamble entry with `heading: nil` and `level: 0`. +Only **top-level** headings start a section. A heading inside a block +quote or list item (such as a `## Tip` in a `> [!NOTE]` callout) stays in +the content of the section around it, so the quote or list is never split +across sections. It still counts toward slug deduplication, so every +section's `:id` matches the anchor `headings: { ids: true }` generates. + `:breadcrumb` carries the ancestor heading texts from root to immediate parent. Root-level sections and the preamble have an empty array. Skipped levels are omitted, so an `###` directly under an `#` has `breadcrumb: @@ -740,6 +749,17 @@ Inkmark.to_markdown(source, options: { }) ``` +### Raw HTML in the source + +With `raw_html: false` (the default), raw HTML from the source comes out of +`to_markdown`, `chunks_by_heading`, `chunks_by_size`, and `truncate_markdown` +escaped, so the Markdown stays inert even if a later stage renders it with raw +HTML allowed. HTML produced by the HTML-emitting filters above is the +exception: it is embedded as described. + +If the filtered document cannot be written back as Markdown, these methods +raise `Inkmark::Error`. + ## Event handlers Register handlers with `#on` to inspect or transform document elements as they diff --git a/ext/inkmark/Cargo.toml b/ext/inkmark/Cargo.toml index f526cba..05ef0a6 100644 --- a/ext/inkmark/Cargo.toml +++ b/ext/inkmark/Cargo.toml @@ -22,7 +22,7 @@ syntect = { version = "5.2", default-features = false, features = ["parsing", "h linkify = "0.11" globset = "0.4" url = "2" -pulldown-cmark-to-cmark = "22" +pulldown-cmark-to-cmark = "23" [build-dependencies] rb-sys-env = "0.2.2" diff --git a/ext/inkmark/src/chunks_by_heading.rs b/ext/inkmark/src/chunks_by_heading.rs index 09ea175..1fb0f54 100644 --- a/ext/inkmark/src/chunks_by_heading.rs +++ b/ext/inkmark/src/chunks_by_heading.rs @@ -14,6 +14,12 @@ //! emoji/autolink rewrites happen *inside* a heading's text events, //! but the bracketing tags stay in place—so post-filter heading- //! position scanning is coherent. +//! +//! Only top-level headings start a section. A heading inside a block +//! quote, list item, or other container stays in the content of the +//! section around it, so a container is never split across sections. +//! Nested headings still consume slugs, keeping section ids aligned +//! with the ids `heading_ids` / `toc` emit. use magnus::{Error, RArray, RHash, Ruby}; use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd}; @@ -21,6 +27,7 @@ use unicode_segmentation::UnicodeSegmentation; use crate::document::{apply_filters, content_events}; use crate::heading::{self, SlugDeduplicator}; +use crate::markdown; use crate::options::build_options; use crate::toc; use crate::truncate::{self, TruncateParams}; @@ -57,8 +64,13 @@ pub fn native_chunks_by_heading( let content_opts = ContentOptions { with_counts: flags.statistics, truncate_params: truncate_params.as_ref(), + md_opts: markdown::serializer_options(cm_opts, &flags), }; - let preamble_end = boundaries.first().map(|b| b.start).unwrap_or(events.len()); + let preamble_end = boundaries + .iter() + .find(|b| b.top_level) + .map(|b| b.start) + .unwrap_or(events.len()); if preamble_end > 0 { let preamble_events = &events[0..preamble_end]; if !is_empty_content(preamble_events) { @@ -78,23 +90,29 @@ pub fn native_chunks_by_heading( let mut dedup = SlugDeduplicator::new(); let mut ancestors: Vec<(u8, String)> = Vec::new(); for (i, boundary) in boundaries.iter().enumerate() { + let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]); + let id = heading_id(&mut dedup, &heading_text); + if !boundary.top_level { + // Stays in the surrounding section's content; only its slug + // counts, so later ids match `heading_ids`. + continue; + } let section_end = find_section_end(&boundaries, i, events.len()); let level = toc::level_to_u8(boundary.level); while ancestors.last().is_some_and(|(l, _)| *l >= level) { ancestors.pop(); } let breadcrumb: Vec<&str> = ancestors.iter().map(|(_, t)| t.as_str()).collect(); - let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]); // Content = events after End(Heading) up to the next section or - // end of document. Re-serialized through cmark_write. + // end of document. Re-serialized through `markdown::render`. let content_events = &events[(boundary.end + 1)..section_end]; let hash = build_section_hash( ruby, boundary.level, &heading_text, + &id, &breadcrumb, content_events, - &mut dedup, &content_opts, )?; result.push(hash)?; @@ -110,6 +128,9 @@ struct HeadingBoundary { start: usize, end: usize, level: HeadingLevel, + /// Not inside a block quote, list item, or other container. Only + /// top-level headings start sections. + top_level: bool, } /// How each entry's `content` is rendered: optionally truncated, and @@ -117,14 +138,20 @@ struct HeadingBoundary { struct ContentOptions<'a> { with_counts: bool, truncate_params: Option<&'a TruncateParams>, + /// Serializer options, from `markdown::serializer_options`. + md_opts: pulldown_cmark::Options, } fn find_heading_boundaries(events: &[Event<'_>]) -> Vec { let mut boundaries = Vec::new(); + // Containers open around the cursor. Headings are leaf blocks, so + // none open at a Start(Heading) means it is not inside anything. + let mut open_containers = 0usize; let mut i = 0; while i < events.len() { if let Event::Start(Tag::Heading { level, .. }) = &events[i] { let lvl = *level; + let top_level = open_containers == 0; // CommonMark disallows headings inside headings, but carry a // depth counter so we stay correct if pulldown-cmark ever // permits them. @@ -147,23 +174,29 @@ fn find_heading_boundaries(events: &[Event<'_>]) -> Vec { start: i, end: j, level: lvl, + top_level, }); i = j + 1; } else { + match &events[i] { + Event::Start(_) => open_containers += 1, + Event::End(_) => open_containers = open_containers.saturating_sub(1), + _ => {} + } i += 1; } } boundaries } -/// Section i ends at the first subsequent heading with level <= -/// current. Headings with a strictly greater level are subsections: +/// Section i ends at the first subsequent top-level heading with level +/// <= current. Headings with a strictly greater level are subsections: /// they belong to the current section's content too. fn find_section_end(boundaries: &[HeadingBoundary], i: usize, events_len: usize) -> usize { let current = toc::level_to_u8(boundaries[i].level); boundaries[(i + 1)..] .iter() - .find(|b| toc::level_to_u8(b.level) <= current) + .find(|b| b.top_level && toc::level_to_u8(b.level) <= current) .map(|b| b.start) .unwrap_or(events_len) } @@ -181,10 +214,7 @@ fn build_preamble_hash( // with proper sections so callers can treat every entry alike. hash.aset(ruby.to_symbol("breadcrumb"), ruby.ary_new_capa(0))?; - let content = match content_opts.truncate_params { - Some(params) => truncate::truncate_events(events, params), - None => render_markdown(events), - }; + let content = render_content(ruby, events, content_opts)?; if content_opts.with_counts { let (chars, words) = count_post_truncate(events, content_opts.truncate_params, &content); hash.aset(ruby.to_symbol("character_count"), chars)?; @@ -198,21 +228,11 @@ fn build_section_hash( ruby: &Ruby, level: HeadingLevel, heading_text: &str, + id: &str, breadcrumb: &[&str], content_events: &[Event<'_>], - dedup: &mut SlugDeduplicator, content_opts: &ContentOptions<'_>, ) -> Result { - // Slug is the deduplicated slugify of the (filter-applied) heading - // text, matching the ids `heading_ids` / `toc` would emit for the - // same document. - let base = heading::slugify(heading_text); - let id = if base.is_empty() { - String::new() - } else { - dedup.deduplicate(base) - }; - let hash = ruby.hash_new(); hash.aset(ruby.to_symbol("heading"), heading_text)?; hash.aset(ruby.to_symbol("level"), toc::level_to_u8(level))?; @@ -227,10 +247,7 @@ fn build_section_hash( } hash.aset(ruby.to_symbol("breadcrumb"), breadcrumb_arr)?; - let content = match content_opts.truncate_params { - Some(params) => truncate::truncate_events(content_events, params), - None => render_markdown(content_events), - }; + let content = render_content(ruby, content_events, content_opts)?; if content_opts.with_counts { let (chars, words) = count_post_truncate(content_events, content_opts.truncate_params, &content); @@ -289,11 +306,24 @@ fn count_text(events: &[Event<'_>]) -> (usize, usize) { (chars, words) } -fn render_markdown(events: &[Event<'_>]) -> String { - let mut buf = String::new(); - pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf) - .expect("markdown serialization failed"); - buf +/// Deduplicated slug of the (filter-applied) heading text, matching the +/// ids `heading_ids` / `toc` would emit for the same document. Empty when +/// the text has nothing to slugify. +fn heading_id(dedup: &mut SlugDeduplicator, heading_text: &str) -> String { + dedup.deduplicate(heading::slugify(heading_text)) +} + +/// Serialize a section's events to Markdown, truncated when requested. +fn render_content( + ruby: &Ruby, + events: &[Event<'_>], + content_opts: &ContentOptions<'_>, +) -> Result { + match content_opts.truncate_params { + Some(params) => truncate::truncate_events(events, params, content_opts.md_opts), + None => markdown::render(events, content_opts.md_opts), + } + .map_err(|e| markdown::to_ruby_error(ruby, e)) } fn collect_inline_text(events: &[Event<'_>]) -> String { @@ -324,3 +354,33 @@ fn is_empty_content(events: &[Event<'_>]) -> bool { ) }) } + +#[cfg(test)] +mod tests { + use super::*; + use pulldown_cmark::Options; + + fn events(source: &str) -> Vec> { + Parser::new_ext(source, Options::empty()).collect() + } + + #[test] + fn headings_inside_containers_are_not_top_level() { + let ev = events("# Top\n\n> ## Quoted\n\n- # Listed\n\n## Sub\n"); + let top_level: Vec = find_heading_boundaries(&ev) + .iter() + .map(|b| b.top_level) + .collect(); + assert_eq!(top_level, vec![true, false, false, true]); + } + + #[test] + fn section_spanning_a_nested_heading_stays_balanced() { + let ev = events("# Top\n\n> ## Inner\n> text\n\nafter\n"); + let boundaries = find_heading_boundaries(&ev); + let end = find_section_end(&boundaries, 0, ev.len()); + let content = &ev[(boundaries[0].end + 1)..end]; + let md = markdown::render(content, Options::empty()).expect("balanced slice"); + assert!(md.contains("Inner") && md.contains("after"), "{md:?}"); + } +} diff --git a/ext/inkmark/src/chunks_by_size.rs b/ext/inkmark/src/chunks_by_size.rs index 5774a44..cad35ba 100644 --- a/ext/inkmark/src/chunks_by_size.rs +++ b/ext/inkmark/src/chunks_by_size.rs @@ -22,6 +22,7 @@ use pulldown_cmark::Event; use unicode_segmentation::UnicodeSegmentation; use crate::document::{apply_filters, content_events}; +use crate::markdown::{self, SerializeError}; use crate::options::build_options; #[derive(Clone, Copy, PartialEq, Eq)] @@ -48,10 +49,12 @@ pub fn native_chunks_by_size( let events: Vec = content_events(&source, cm_opts).collect(); let events = apply_filters(events, &flags); + let md_opts = markdown::serializer_options(cm_opts, &flags); let windows = match params.at { - BoundaryAt::Block => chunk_blocks(&events, ¶ms), - BoundaryAt::Word => chunk_words(&events, ¶ms), - }; + BoundaryAt::Block => chunk_blocks(&events, ¶ms, md_opts), + BoundaryAt::Word => chunk_words(&events, ¶ms, md_opts), + } + .map_err(|e| markdown::to_ruby_error(ruby, e))?; build_result(ruby, &windows, flags.statistics) } @@ -79,20 +82,30 @@ fn parse_params(ruby: &Ruby, hash: &RHash) -> Result { }) } -fn chunk_blocks(events: &[Event<'_>], params: &WindowParams) -> Vec { +fn chunk_blocks( + events: &[Event<'_>], + params: &WindowParams, + md_opts: pulldown_cmark::Options, +) -> Result, SerializeError> { let mut out: Vec = Vec::new(); let mut current = String::new(); let mut current_chars: usize = 0; let mut current_words: usize = 0; for (start, end) in top_level_blocks(events) { - let block = render_markdown(&events[start..=end]); + let block = markdown::render_block(&events[start..=end], md_opts)?; let block_chars = block.chars().count(); let block_words = block.unicode_words().count(); + // The blank line rejoining blocks counts toward the char budget. + let separator_chars = if current.is_empty() { + 0 + } else { + markdown::BLOCK_SEPARATOR.len() + }; let would_exceed_chars = params .chars - .map(|b| current_chars + block_chars > b) + .map(|b| current_chars + separator_chars + block_chars > b) .unwrap_or(false); let would_exceed_words = params .words @@ -110,6 +123,11 @@ fn chunk_blocks(events: &[Event<'_>], params: &WindowParams) -> Vec { out.push(finished); } + // Re-checked: a flush leaves `current` holding the overlap seed. + if !current.is_empty() { + current.push_str(markdown::BLOCK_SEPARATOR); + current_chars += markdown::BLOCK_SEPARATOR.len(); + } current.push_str(&block); current_chars += block_chars; current_words += block_words; @@ -118,7 +136,7 @@ fn chunk_blocks(events: &[Event<'_>], params: &WindowParams) -> Vec { if !current.is_empty() { out.push(current); } - out + Ok(out) } /// Return the trailing `overlap` characters of `s`, aligned to a @@ -167,13 +185,6 @@ fn top_level_blocks(events: &[Event<'_>]) -> Vec<(usize, usize)> { blocks } -fn render_markdown(events: &[Event<'_>]) -> String { - let mut buf = String::new(); - pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf) - .expect("markdown serialization failed"); - buf -} - // Serialize the full filtered Markdown into one string, then walk // word boundaries. Each window is a byte-aligned slice of the // serialized output ending at a word boundary that fits the budget. @@ -181,10 +192,14 @@ fn render_markdown(events: &[Event<'_>]) -> String { // `overlap` chars (to the next word boundary) before taking the // next window. -fn chunk_words(events: &[Event<'_>], params: &WindowParams) -> Vec { - let rendered = render_markdown(events); +fn chunk_words( + events: &[Event<'_>], + params: &WindowParams, + md_opts: pulldown_cmark::Options, +) -> Result, SerializeError> { + let rendered = markdown::render(events, md_opts)?; if rendered.is_empty() { - return Vec::new(); + return Ok(Vec::new()); } let mut out: Vec = Vec::new(); @@ -250,7 +265,7 @@ fn chunk_words(events: &[Event<'_>], params: &WindowParams) -> Vec { }; } - out + Ok(out) } /// Step the cursor back by roughly `overlap` characters, then land on diff --git a/ext/inkmark/src/document.rs b/ext/inkmark/src/document.rs index 4c30a74..0cc64a3 100644 --- a/ext/inkmark/src/document.rs +++ b/ext/inkmark/src/document.rs @@ -7,6 +7,7 @@ use crate::heading; use crate::highlight; use crate::image; use crate::link; +use crate::markdown::{self, SerializeError}; use crate::options::{build_options, Flags}; use crate::plain_text; use crate::scheme_filter::SchemeFilter; @@ -36,13 +37,14 @@ pub fn native_to_markdown( source: String, opts_hash: Option, ) -> Result { - match opts_hash { - None => Ok(markdown_defaults(&source)), + let rendered = match opts_hash { + None => markdown_defaults(&source), Some(hash) => { let (cm_opts, flags) = build_options(ruby, hash)?; - Ok(render_to_markdown(&source, cm_opts, flags)) + render_to_markdown(&source, cm_opts, flags) } - } + }; + rendered.map_err(|e| markdown::to_ruby_error(ruby, e)) } pub fn native_to_plain_text( @@ -71,12 +73,13 @@ fn render_defaults(source: &str) -> String { } /// Same as render_defaults but serializes to Markdown instead of HTML. -fn markdown_defaults(source: &str) -> String { +fn markdown_defaults(source: &str) -> Result { let mut buf = String::with_capacity(source.len()); - let parser = Parser::new_ext(source, default_cm_opts()); + let cm_opts = default_cm_opts(); + let parser = Parser::new_ext(source, cm_opts); let filtered = parser.map(suppress_raw_html); - cmark_write(filtered, &mut buf); - buf + markdown::write(filtered, &mut buf, cm_opts)?; + Ok(buf) } /// Defaults-only plain-text fast path. Mirrors `markdown_defaults`: @@ -255,22 +258,25 @@ fn render(source: &str, cm_opts: pulldown_cmark::Options, flags: Flags) -> Strin buf } -fn render_to_markdown(source: &str, cm_opts: pulldown_cmark::Options, flags: Flags) -> String { - let mut buf = String::with_capacity(source.len()); - +fn render_to_markdown( + source: &str, + cm_opts: pulldown_cmark::Options, + flags: Flags, +) -> Result { // `content_events` strips frontmatter, so the cmark serializer never sees // a metadata block to re-emit—on either the streaming or buffered path. if !needs_buffer(&flags) { - cmark_write( + let mut buf = String::with_capacity(source.len()); + markdown::write( content_events(source, cm_opts).map(stream_filter(&flags)), &mut buf, - ); - return buf; + cm_opts, + )?; + return Ok(buf); } let events = apply_filters(content_events(source, cm_opts).collect(), &flags); - cmark_write(events.into_iter(), &mut buf); - buf + markdown::render(&events, markdown::serializer_options(cm_opts, &flags)) } fn render_to_plain_text(source: &str, cm_opts: pulldown_cmark::Options, flags: Flags) -> String { @@ -447,7 +453,3 @@ pub fn apply_post_handler_filters<'a>(mut events: Vec>, flags: &Flags) events } - -fn cmark_write<'a, I: Iterator>>(events: I, buf: &mut String) { - pulldown_cmark_to_cmark::cmark(events, buf).expect("markdown serialization failed"); -} diff --git a/ext/inkmark/src/image.rs b/ext/inkmark/src/image.rs index d76360a..38fddd9 100644 --- a/ext/inkmark/src/image.rs +++ b/ext/inkmark/src/image.rs @@ -1,7 +1,7 @@ //! Image attribute injection filter and URL matcher. //! //! When enabled, replaces pulldown-cmark's default image event sequence -//! (`Start(Tag::Image) ... End(TagEnd::Image)`) with a single `Event::Html` +//! (`Start(Tag::Image) ... End(TagEnd::Image)`) with a single `Event::InlineHtml` //! carrying a hand-built `` tag that includes the "modern" loading and //! decoding hints: //! @@ -23,7 +23,7 @@ use pulldown_cmark_escape::{escape_href, escape_html}; use crate::url_match::is_host_allowed; -/// Rewrite every image in the event stream as a self-contained `Event::Html` +/// Rewrite every image in the event stream as a self-contained `Event::InlineHtml` /// carrying ``. /// /// We consume the input Vec to own each event, then rebuild with @@ -38,22 +38,9 @@ pub fn add_lazy_loading(events: Vec>) -> Vec> { Event::Start(Tag::Image { dest_url, title, .. }) => { - // Consume events up to the matching End(Image), accumulating - // alt text from Text and Code payloads. Images can contain - // inline formatting (e.g. `![**bold**](img.png)`), which - // produces Start(Strong)/Text/End(Strong) events; the bare - // text content is what we want for the alt attribute. - let mut alt = String::new(); - for inner in iter.by_ref() { - match inner { - Event::End(TagEnd::Image) => break, - Event::Text(t) | Event::Code(t) => alt.push_str(&t), - _ => {} - } - } - + let alt = take_alt_text(&mut iter); let html = build_img_tag(&dest_url, &alt, &title); - out.push(Event::Html(CowStr::Boxed(html.into_boxed_str()))); + out.push(Event::InlineHtml(CowStr::Boxed(html.into_boxed_str()))); } other => out.push(other), } @@ -68,10 +55,8 @@ pub fn add_lazy_loading(events: Vec>) -> Vec> { /// when alt is empty. Non-web URLs pass through: [`is_host_allowed`] /// returns true for any URL with no parseable host. /// -/// Alt accumulation matches `add_lazy_loading`: images can contain -/// markdown like `![**bold**](img.png)`, producing -/// Start(Strong)/Text/End(Strong) events—we pull the raw text payloads -/// out and discard formatting. +/// Alt accumulation is shared with `add_lazy_loading` (see +/// [`take_alt_text`]). pub fn filter_by_hosts<'a>(events: Vec>, set: &GlobSet) -> Vec> { let mut out: Vec> = Vec::with_capacity(events.len()); let mut iter = events.into_iter(); @@ -79,14 +64,7 @@ pub fn filter_by_hosts<'a>(events: Vec>, set: &GlobSet) -> Vec { - let mut alt = String::new(); - for inner in iter.by_ref() { - match inner { - Event::End(TagEnd::Image) => break, - Event::Text(t) | Event::Code(t) => alt.push_str(&t), - _ => {} - } - } + let alt = take_alt_text(&mut iter); if !alt.is_empty() { out.push(Event::Text(CowStr::Boxed(alt.into_boxed_str()))); } @@ -98,6 +76,30 @@ pub fn filter_by_hosts<'a>(events: Vec>, set: &GlobSet) -> Vec(iter: &mut impl Iterator>) -> String { + let mut alt = String::new(); + let mut depth: usize = 1; + for inner in iter { + match inner { + Event::Start(Tag::Image { .. }) => depth += 1, + Event::End(TagEnd::Image) => { + depth -= 1; + if depth == 0 { + break; + } + } + Event::Text(t) | Event::Code(t) => alt.push_str(&t), + _ => {} + } + } + alt +} + /// Construct the `` HTML string with `loading="lazy"` and /// `decoding="async"` attributes. `src` is escaped as a URL (percent- /// encoded where necessary); `alt` and `title` are HTML-attribute @@ -201,7 +203,7 @@ mod tests { let out = add_lazy_loading(events); assert_eq!(out.len(), 1, "should collapse to one event"); match &out[0] { - Event::Html(html) => { + Event::InlineHtml(html) => { assert!( html.contains("loading="), "missing loading attribute: {html}" @@ -233,6 +235,31 @@ mod tests { } } + fn has_image_event(events: &[Event<'_>]) -> bool { + events.iter().any(|e| { + matches!( + e, + Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) + ) + }) + } + + #[test] + fn image_nested_in_alt_text_is_consumed_with_its_parent() { + let source = "![foo ![bar](https://evil.com/1)](https://evil.com/2)"; + let events: Vec = pulldown_cmark::Parser::new(source).collect(); + + let dropped = filter_by_hosts(events.clone(), &host_set(&["example.net"])); + assert!(!has_image_event(&dropped), "stray image event: {dropped:?}"); + assert!(dropped.contains(&Event::Text(CowStr::Borrowed("foo bar")))); + + let lazy = add_lazy_loading(events); + assert!(!has_image_event(&lazy), "stray image event: {lazy:?}"); + assert!(lazy + .iter() + .any(|e| matches!(e, Event::InlineHtml(h) if h.contains("alt=\"foo bar\"")))); + } + #[test] fn filter_by_hosts_drops_disallowed_image_with_empty_alt_entirely() { let events = vec![ diff --git a/ext/inkmark/src/lib.rs b/ext/inkmark/src/lib.rs index ca3e7c1..285eda0 100644 --- a/ext/inkmark/src/lib.rs +++ b/ext/inkmark/src/lib.rs @@ -14,6 +14,7 @@ mod heading; mod highlight; mod image; mod link; +mod markdown; mod options; mod plain_text; mod scheme_filter; diff --git a/ext/inkmark/src/link.rs b/ext/inkmark/src/link.rs index f3f09e5..a3c30d8 100644 --- a/ext/inkmark/src/link.rs +++ b/ext/inkmark/src/link.rs @@ -18,8 +18,9 @@ use pulldown_cmark_escape::{escape_href, escape_html}; use crate::url_match::is_host_allowed; /// Add `rel="nofollow noopener"` to every external `` tag by replacing -/// its `Start(Link)` event with a synthesized `Event::Html` opening tag -/// and its matching `End(Link)` event with a `` close tag. +/// its `Start(Link)` event with a synthesized `Event::InlineHtml` opening +/// tag and its matching `End(Link)` event with a `` close tag. Inline, +/// not block, HTML: the Markdown serializer needs to know it sits in a line. pub fn add_nofollow(events: Vec>) -> Vec> { let mut out: Vec> = Vec::with_capacity(events.len()); let mut iter = events.into_iter(); @@ -33,7 +34,7 @@ pub fn add_nofollow(events: Vec>) -> Vec> { id: _, }) if is_external(dest_url) => { let open = build_link_open(dest_url, title); - out.push(Event::Html(CowStr::Boxed(open.into_boxed_str()))); + out.push(Event::InlineHtml(CowStr::Boxed(open.into_boxed_str()))); // Consume inner events through the matching End(Link), // depth-counting so a nested link doesn't break the @@ -51,7 +52,7 @@ pub fn add_nofollow(events: Vec>) -> Vec> { } else if is_link_end { depth -= 1; if depth == 0 { - out.push(Event::Html(CowStr::Borrowed(""))); + out.push(Event::InlineHtml(CowStr::Borrowed(""))); break; } out.push(inner); @@ -209,10 +210,10 @@ mod tests { Event::End(TagEnd::Link), ]; let out = add_nofollow(events); - // Should produce: Html(open), Text("click"), Html("") + // Should produce: InlineHtml(open), Text("click"), InlineHtml("") assert_eq!(out.len(), 3, "expected 3 events, got {}", out.len()); match &out[0] { - Event::Html(html) => { + Event::InlineHtml(html) => { assert!( html.contains("nofollow"), "opening tag must contain nofollow: {html}" @@ -222,11 +223,11 @@ mod tests { "opening tag must contain href: {html}" ); } - other => panic!("expected Html open event, got {other:?}"), + other => panic!("expected InlineHtml open event, got {other:?}"), } match &out[2] { - Event::Html(html) => assert_eq!(html.as_ref(), ""), - other => panic!("expected Html close event, got {other:?}"), + Event::InlineHtml(html) => assert_eq!(html.as_ref(), ""), + other => panic!("expected InlineHtml close event, got {other:?}"), } } diff --git a/ext/inkmark/src/markdown.rs b/ext/inkmark/src/markdown.rs new file mode 100644 index 0000000..3c09cc9 --- /dev/null +++ b/ext/inkmark/src/markdown.rs @@ -0,0 +1,436 @@ +//! Markdown serialization through `pulldown-cmark-to-cmark`. +//! +//! Every Markdown-emitting API (`to_markdown`, `chunks_by_heading`, +//! `chunks_by_size`, `truncate_markdown`) funnels through this module so +//! the serializer always sees the parser options that produced the +//! events: pulldown-cmark-to-cmark uses them to pick escapes that reparse +//! to the same document. +//! +//! Serializer failures come back as `Err` instead of panicking (a panic +//! reaches Ruby as `fatal`, which cannot be rescued). Native entry points +//! turn them into `Inkmark::Error` via [`to_ruby_error`]. +//! +//! Two entry points: [`write`] streams parser output straight through +//! (the unfiltered fast path); [`render`] takes filtered events and falls +//! back to [`tidy_inline_edges`] when the serializer rejects them. + +use std::collections::VecDeque; + +use magnus::{prelude::*, Error, ExceptionClass, RClass, Ruby}; +use pulldown_cmark::{CowStr, Event, Options, Tag, TagEnd}; + +use crate::options::Flags; + +pub use pulldown_cmark_to_cmark::Error as SerializeError; + +/// Options to serialize a filtered document with: the parser options it +/// was parsed with, plus what the filters' output needs. The `heading_ids` +/// filter gives headings an `id`, which Markdown can only express as a +/// `{ #id }` heading attribute, so heading attributes are switched on for +/// the serializer (matching 0.2's output) instead of it failing on +/// headings it cannot otherwise preserve. +pub fn serializer_options(cm_opts: Options, flags: &Flags) -> Options { + if flags.heading_ids { + cm_opts | Options::ENABLE_HEADING_ATTRIBUTES + } else { + cm_opts + } +} + +/// Serialize unfiltered parser output into `buf`. `md_opts` must be the +/// options the events were parsed with. +pub fn write<'a, I>(events: I, buf: &mut String, md_opts: Options) -> Result<(), SerializeError> +where + I: Iterator>, +{ + pulldown_cmark_to_cmark::cmark(InertHtmlBlocks::new(events), buf, md_opts)?; + Ok(()) +} + +/// Serialize filtered events into a new String. `md_opts` comes from +/// [`serializer_options`]. +/// +/// Filters can leave shapes the serializer cannot write (see +/// [`tidy_inline_edges`]). Parser output never has them, so events are +/// reshaped only after a rejection, then serialized once more: output that +/// serializes as-is is never touched. +pub fn render(events: &[Event<'_>], md_opts: Options) -> Result { + let mut buf = String::new(); + if write(events.iter().cloned(), &mut buf, md_opts).is_ok() { + return Ok(buf); + } + let mut events = events.to_vec(); + tidy_inline_edges(&mut events); + let mut buf = String::new(); + write(events.into_iter(), &mut buf, md_opts)?; + Ok(buf) +} + +/// Blank line that rejoins top-level blocks serialized one at a time. +/// Without it, adjacent paragraphs would merge on reparse. +pub const BLOCK_SEPARATOR: &str = "\n\n"; + +/// Serialize one top-level block for joining with [`BLOCK_SEPARATOR`]: +/// rendered alone, minus the newlines it starts or ends with. +pub fn render_block(events: &[Event<'_>], md_opts: Options) -> Result { + Ok(render(events, md_opts)?.trim_matches('\n').to_owned()) +} + +/// Convert a serializer failure into an `Inkmark::Error` exception. +/// +/// The class is looked up on each call rather than cached: the extension +/// keeps no Ruby `VALUE`s in process-global state (see `rb_ext_ractor_safe` +/// in `lib.rs`), and this path only runs on failure. +pub fn to_ruby_error(ruby: &Ruby, err: SerializeError) -> Error { + let class = ruby + .class_object() + .const_get::<_, RClass>("Inkmark") + .and_then(|inkmark| inkmark.const_get::<_, ExceptionClass>("Error")); + match class { + Ok(class) => Error::new(class, format!("markdown serialization failed: {err}")), + Err(lookup_failed) => lookup_failed, + } +} + +/// Inline containers written as delimiter runs (`*`, `**`, `~~`, `^`, `~`). +/// CommonMark only recognizes a run that hugs non-whitespace, so these +/// cannot be empty or start or end with whitespace or a line break. +fn is_delimited_start(event: Option<&Event<'_>>) -> bool { + matches!( + event, + Some(Event::Start( + Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Superscript | Tag::Subscript + )) + ) +} + +fn is_delimited_end(end: &TagEnd) -> bool { + matches!( + end, + TagEnd::Emphasis + | TagEnd::Strong + | TagEnd::Strikethrough + | TagEnd::Superscript + | TagEnd::Subscript + ) +} + +/// Block containers: a line break cannot open or close one. +fn is_block_start(event: Option<&Event<'_>>) -> bool { + matches!(event, Some(Event::Start(tag)) if !matches!(tag, + Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Superscript + | Tag::Subscript | Tag::Link { .. } | Tag::Image { .. })) +} + +fn is_block_end(end: &TagEnd) -> bool { + !is_delimited_end(end) && !matches!(end, TagEnd::Link | TagEnd::Image) +} + +fn is_break(event: Option<&Event<'_>>) -> bool { + matches!(event, Some(Event::SoftBreak | Event::HardBreak)) +} + +/// What the parser strips at both ends of every line of inline content. +fn is_line_space(c: char) -> bool { + c == ' ' || c == '\t' +} + +/// A line of inline content starts after this event. +fn opens_line(event: Option<&Event<'_>>) -> bool { + is_break(event) + || matches!( + event, + Some(Event::Start( + Tag::Paragraph | Tag::Heading { .. } | Tag::TableCell | Tag::Item + )) + ) +} + +/// A line of inline content ends at this tag. +fn closes_line(end: &TagEnd) -> bool { + matches!( + end, + TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::TableCell | TagEnd::Item + ) +} + +fn last_text_ends_with_line_space(out: &[Event<'_>]) -> bool { + matches!(out.last(), Some(Event::Text(t)) if t.ends_with(is_line_space)) +} + +fn trim_last_text_end(out: &mut Vec>) { + if let Some(Event::Text(text)) = out.pop() { + let body = text.trim_end_matches(is_line_space); + if !body.is_empty() { + out.push(Event::Text(CowStr::from(body.to_owned()))); + } + } +} + +/// Reshape filtered events the serializer could not otherwise write. +/// +/// Filters can leave a delimited container with whitespace at an edge or +/// with nothing inside, typically by unwrapping a disallowed link or image +/// so its text lands directly inside `**...**`. Line breaks and whitespace +/// at an edge move outside the container, emptied containers are dropped, +/// a line break left opening or closing a block is dropped, and spaces or +/// tabs left at the start or end of a line are trimmed. Rendering is +/// unchanged (`a ` displays like `a `). +fn tidy_inline_edges(events: &mut Vec>) { + // Each pass fixes one nesting level per edge; repeat until stable. + // Every change shortens the list or moves an event strictly outward, + // so this terminates. + while tidy_pass(events) {} +} + +fn tidy_pass(events: &mut Vec>) -> bool { + let mut changed = false; + let mut out: Vec> = Vec::with_capacity(events.len()); + for event in events.drain(..) { + match event { + Event::End(end) if is_delimited_end(&end) && is_delimited_start(out.last()) => { + out.pop(); + changed = true; + } + Event::End(end) if is_delimited_end(&end) && is_break(out.last()) => { + let brk = out.pop().expect("checked non-empty"); + out.push(Event::End(end)); + out.push(brk); + changed = true; + } + Event::End(end) + if is_delimited_end(&end) + && matches!(out.last(), Some(Event::Text(t)) if t.ends_with(char::is_whitespace)) => + { + let Some(Event::Text(text)) = out.pop() else { + unreachable!("checked above") + }; + let body = text.trim_end(); + let space = text[body.len()..].to_owned(); + if !body.is_empty() { + out.push(Event::Text(CowStr::from(body.to_owned()))); + } + out.push(Event::End(end)); + out.push(Event::Text(CowStr::from(space))); + changed = true; + } + Event::End(end) if is_block_end(&end) && is_break(out.last()) => { + out.pop(); + out.push(Event::End(end)); + changed = true; + } + Event::SoftBreak | Event::HardBreak if is_delimited_start(out.last()) => { + let start = out.pop().expect("checked non-empty"); + out.push(event); + out.push(start); + changed = true; + } + Event::SoftBreak | Event::HardBreak if is_block_start(out.last()) => { + changed = true; + } + Event::Text(text) + if text.starts_with(char::is_whitespace) && is_delimited_start(out.last()) => + { + let start = out.pop().expect("checked non-empty"); + let body = text.trim_start(); + let space = text[..text.len() - body.len()].to_owned(); + out.push(Event::Text(CowStr::from(space))); + out.push(start); + if !body.is_empty() { + out.push(Event::Text(CowStr::from(body.to_owned()))); + } + changed = true; + } + Event::End(end) if closes_line(&end) && last_text_ends_with_line_space(&out) => { + trim_last_text_end(&mut out); + out.push(Event::End(end)); + changed = true; + } + Event::SoftBreak | Event::HardBreak if last_text_ends_with_line_space(&out) => { + trim_last_text_end(&mut out); + out.push(event); + changed = true; + } + Event::Text(text) if text.starts_with(is_line_space) && opens_line(out.last()) => { + let body = text.trim_start_matches(is_line_space); + if !body.is_empty() { + out.push(Event::Text(CowStr::from(body.to_owned()))); + } + changed = true; + } + other => out.push(other), + } + } + *events = out; + changed +} + +/// Keeps suppressed raw HTML inert in Markdown output. +/// +/// `suppress_raw_html` turns `Html` events into `Text` but leaves the +/// `HtmlBlock` container around them, which is what the HTML renderer +/// wants (escaped text, no `

`). The Markdown serializer, though, writes +/// an `HtmlBlock`'s contents verbatim, so that text would reparse as live +/// HTML. Blocks whose contents start with `Text` are rewritten as a +/// paragraph, one trimmed line per `SoftBreak`, so the serializer escapes +/// them. Blocks of real `Html` events (`raw_html: true`) pass through. +struct InertHtmlBlocks<'a, I: Iterator>> { + inner: std::iter::Peekable, + pending: VecDeque>, +} + +impl<'a, I: Iterator>> InertHtmlBlocks<'a, I> { + fn new(inner: I) -> Self { + Self { + inner: inner.peekable(), + pending: VecDeque::new(), + } + } + + /// Consume the rest of a suppressed HTML block (through its `End`) and + /// queue it as a paragraph. + fn queue_as_paragraph(&mut self) { + let mut lines: Vec = Vec::new(); + loop { + match self.inner.next() { + // An `Html` event in a block that was otherwise suppressed + // is escaped too: staying inert is the safe default. + Some(Event::Text(t)) | Some(Event::Html(t)) => lines.extend( + t.split('\n') + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_owned), + ), + Some(Event::End(TagEnd::HtmlBlock)) | None => break, + Some(_) => {} + } + } + if lines.is_empty() { + return; + } + self.pending.push_back(Event::Start(Tag::Paragraph)); + for (i, line) in lines.into_iter().enumerate() { + if i > 0 { + self.pending.push_back(Event::SoftBreak); + } + self.pending.push_back(Event::Text(CowStr::from(line))); + } + self.pending.push_back(Event::End(TagEnd::Paragraph)); + } +} + +impl<'a, I: Iterator>> Iterator for InertHtmlBlocks<'a, I> { + type Item = Event<'a>; + + fn next(&mut self) -> Option> { + loop { + if let Some(event) = self.pending.pop_front() { + return Some(event); + } + match self.inner.next()? { + Event::Start(Tag::HtmlBlock) + if matches!(self.inner.peek(), Some(Event::Text(_))) => + { + // May queue nothing (all-blank block); loop either way. + self.queue_as_paragraph(); + } + event => return Some(event), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pulldown_cmark::Parser; + + fn suppressed(source: &str) -> Vec> { + Parser::new_ext(source, Options::empty()) + .map(|e| match e { + Event::Html(h) | Event::InlineHtml(h) => Event::Text(h), + other => other, + }) + .collect() + } + + fn reparse_has_html(markdown: &str) -> bool { + Parser::new_ext(markdown, Options::empty()) + .any(|e| matches!(e, Event::Html(_) | Event::InlineHtml(_))) + } + + #[test] + fn suppressed_html_block_stays_inert() { + let md = render( + &suppressed("

\n*inside*\n
\n"), + Options::empty(), + ) + .unwrap(); + assert!(!reparse_has_html(&md), "reparsed as live HTML: {md:?}"); + assert!(md.contains("div"), "lost the escaped text: {md:?}"); + } + + #[test] + fn suppressed_html_block_drops_blank_lines() { + // HTML block types 1-5 (here \n"), + Options::empty(), + ) + .unwrap(); + assert!(!reparse_has_html(&md), "reparsed as live HTML: {md:?}"); + assert!(md.contains("alert(1)"), "lost the escaped text: {md:?}"); + } + + #[test] + fn live_html_block_passes_through() { + let events: Vec = + Parser::new_ext("
\nraw\n
\n", Options::empty()).collect(); + let md = render(&events, Options::empty()).unwrap(); + assert!(md.contains("
"), "raw HTML was rewritten: {md:?}"); + } + + /// Parse `source` and drop every link's Start/End, as the allowlist + /// filters do when they unwrap a disallowed link to its text. + fn unwrapped_links(source: &str) -> Vec> { + Parser::new_ext(source, Options::empty()) + .filter(|e| !matches!(e, Event::Start(Tag::Link { .. }) | Event::End(TagEnd::Link))) + .collect() + } + + #[test] + fn unwrapped_link_edges_inside_emphasis_serialize() { + for source in [ + "**[text\n](u)**", + "**[\ntext](u)**", + "**[text ](u)**", + "**[ text](u)**", + "**_[text\n](u)_**", + "a **[text ](u)** b", + ] { + let md = render(&unwrapped_links(source), Options::empty()) + .unwrap_or_else(|e| panic!("{source:?} failed: {e}")); + let strong = Parser::new_ext(&md, Options::empty()) + .any(|e| matches!(e, Event::Start(Tag::Strong))); + assert!(strong && md.contains("text"), "{source:?} => {md:?}"); + } + } + + #[test] + fn emphasis_emptied_by_unwrapping_is_dropped() { + let md = render(&unwrapped_links("a *[](u)* b"), Options::empty()).unwrap(); + assert!(!md.contains('*'), "{md:?}"); + } + + #[test] + fn unbalanced_events_return_an_error_instead_of_panicking() { + let events = vec![ + Event::Start(Tag::Paragraph), + Event::Text(CowStr::Borrowed("x")), + Event::End(TagEnd::Paragraph), + Event::End(TagEnd::BlockQuote(None)), + ]; + assert!(render(&events, Options::empty()).is_err()); + } +} diff --git a/ext/inkmark/src/scheme_filter.rs b/ext/inkmark/src/scheme_filter.rs index 159e87f..15b7127 100644 --- a/ext/inkmark/src/scheme_filter.rs +++ b/ext/inkmark/src/scheme_filter.rs @@ -35,8 +35,10 @@ pub struct SchemeFilter<'e, 's, I: Iterator>> { // disallows nested links so this is usually 1, but the counter keeps // us correct on malformed / extension-emitted streams. skipping_link_depth: usize, - // State for in-progress image drop. - skipping_image: bool, + // State for in-progress image drop, counted like links: an image can + // nest in another image's alt text (`![a ![b](x)](y)`). 0 = not + // skipping. + skipping_image_depth: usize, image_alt: String, } @@ -51,7 +53,7 @@ impl<'e, 's, I: Iterator>> SchemeFilter<'e, 's, I> { link_allowed, image_allowed, skipping_link_depth: 0, - skipping_image: false, + skipping_image_depth: 0, image_alt: String::new(), } } @@ -63,10 +65,17 @@ impl<'e, 's, I: Iterator>> Iterator for SchemeFilter<'e, 's, I> fn next(&mut self) -> Option> { loop { // Image skipping mode - if self.skipping_image { + if self.skipping_image_depth > 0 { match self.inner.next()? { + Event::Start(Tag::Image { .. }) => { + self.skipping_image_depth += 1; + continue; + } Event::End(TagEnd::Image) => { - self.skipping_image = false; + self.skipping_image_depth -= 1; + if self.skipping_image_depth > 0 { + continue; + } if !self.image_alt.is_empty() { let alt = std::mem::take(&mut self.image_alt); return Some(Event::Text(CowStr::Boxed(alt.into_boxed_str()))); @@ -117,7 +126,7 @@ impl<'e, 's, I: Iterator>> Iterator for SchemeFilter<'e, 's, I> .image_allowed .is_some_and(|s| !is_scheme_allowed(dest_url, s)) => { - self.skipping_image = true; + self.skipping_image_depth = 1; self.image_alt.clear(); continue; } @@ -162,6 +171,22 @@ mod tests { assert!(matches!(out[0], Event::Start(Tag::Link { .. }))); } + #[test] + fn drops_blocked_image_with_an_image_nested_in_its_alt_text() { + let source = "![foo ![bar](http://x.com/1)](http://x.com/2)"; + let events: Vec = pulldown_cmark::Parser::new(source).collect(); + let allowed = schemes(&["https"]); + let out = run(events, None, Some(&allowed)); + assert!( + !out.iter().any(|e| matches!( + e, + Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) + )), + "stray image event: {out:?}" + ); + assert!(out.contains(&Event::Text(CowStr::Borrowed("foo bar")))); + } + #[test] fn drops_blocked_link_keeps_text() { let events = vec![ diff --git a/ext/inkmark/src/truncate.rs b/ext/inkmark/src/truncate.rs index bafa5cc..93b0936 100644 --- a/ext/inkmark/src/truncate.rs +++ b/ext/inkmark/src/truncate.rs @@ -11,6 +11,7 @@ use pulldown_cmark::Event; use unicode_segmentation::UnicodeSegmentation; use crate::document::{apply_filters, content_events}; +use crate::markdown::{self, SerializeError}; use crate::options::{build_options, Flags}; /// What kind of boundary to cut at. @@ -43,10 +44,14 @@ pub fn truncate_source( cm_opts: pulldown_cmark::Options, flags: &Flags, params: &TruncateParams, -) -> String { +) -> Result { let events: Vec = content_events(source, cm_opts).collect(); let events = apply_filters(events, flags); - truncate_events(&events, params) + truncate_events( + &events, + params, + markdown::serializer_options(cm_opts, flags), + ) } /// Ruby-facing entry point. Expects `params_hash` to contain @@ -61,7 +66,7 @@ pub fn native_truncate_markdown( ) -> Result { let (cm_opts, flags) = build_options(ruby, opts_hash)?; let params = parse_params(ruby, params_hash)?; - Ok(truncate_source(&source, cm_opts, &flags, ¶ms)) + truncate_source(&source, cm_opts, &flags, ¶ms).map_err(|e| markdown::to_ruby_error(ruby, e)) } pub fn parse_params(ruby: &Ruby, hash: RHash) -> Result { @@ -85,14 +90,22 @@ pub fn parse_params(ruby: &Ruby, hash: RHash) -> Result { } /// Core truncation over a filter-applied event slice. -pub fn truncate_events(events: &[Event<'_>], params: &TruncateParams) -> String { +pub fn truncate_events( + events: &[Event<'_>], + params: &TruncateParams, + md_opts: pulldown_cmark::Options, +) -> Result { match params.at { - TruncateAt::Block => truncate_at_block(events, params), - TruncateAt::Word => truncate_at_word(events, params), + TruncateAt::Block => truncate_at_block(events, params, md_opts), + TruncateAt::Word => truncate_at_word(events, params, md_opts), } } -fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String { +fn truncate_at_block( + events: &[Event<'_>], + params: &TruncateParams, + md_opts: pulldown_cmark::Options, +) -> Result { let marker_chars = marker_chars(¶ms.marker); let marker_words = marker_words(¶ms.marker); let char_budget = params.chars.map(|n| n.saturating_sub(marker_chars)); @@ -104,8 +117,14 @@ fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String { let mut any_dropped = false; for (start, end) in top_level_blocks(events) { - let block = render_markdown(&events[start..=end]); - let block_chars = block.trim_end().chars().count(); + let block = markdown::render_block(&events[start..=end], md_opts)?; + // The blank line rejoining blocks counts toward the char budget. + let separator = if kept.is_empty() { + "" + } else { + markdown::BLOCK_SEPARATOR + }; + let block_chars = separator.len() + block.trim_end().chars().count(); let block_words = block.unicode_words().count(); let would_exceed_chars = char_budget @@ -120,6 +139,7 @@ fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String { break; } + kept.push_str(separator); kept.push_str(&block); used_chars += block_chars; used_words += block_words; @@ -127,12 +147,12 @@ fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String { // If nothing was dropped and source fit, return unchanged (no marker). if !any_dropped { - return kept; + return Ok(kept); } // First block alone exceeded the budget; honest empty return if kept.is_empty() { - return String::new(); + return Ok(String::new()); } // Trim trailing whitespace before appending the marker so the @@ -145,7 +165,7 @@ fn truncate_at_block(events: &[Event<'_>], params: &TruncateParams) -> String { kept.push_str(marker); } kept.push('\n'); - kept + Ok(kept) } /// Return (start, end) event-index pairs for top-level blocks. @@ -185,15 +205,12 @@ fn top_level_blocks(events: &[Event<'_>]) -> Vec<(usize, usize)> { blocks } -fn render_markdown(events: &[Event<'_>]) -> String { - let mut buf = String::new(); - pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf) - .expect("markdown serialization failed"); - buf -} - -fn truncate_at_word(events: &[Event<'_>], params: &TruncateParams) -> String { - let rendered = render_markdown(events); +fn truncate_at_word( + events: &[Event<'_>], + params: &TruncateParams, + md_opts: pulldown_cmark::Options, +) -> Result { + let rendered = markdown::render(events, md_opts)?; let marker_chars = marker_chars(¶ms.marker); let marker_words = marker_words(¶ms.marker); @@ -211,7 +228,7 @@ fn truncate_at_word(events: &[Event<'_>], params: &TruncateParams) -> String { // Fits under both budgets: return unchanged, no marker. if chars_ok && words_ok { - return rendered; + return Ok(rendered); } let char_budget = params.chars.map(|n| n.saturating_sub(marker_chars)); @@ -252,7 +269,7 @@ fn truncate_at_word(events: &[Event<'_>], params: &TruncateParams) -> String { if let Some(marker) = ¶ms.marker { out.push_str(marker); } - out + Ok(out) } fn marker_chars(marker: &Option) -> usize { diff --git a/spec/inkmark_spec.rb b/spec/inkmark_spec.rb index 528fed3..e2d353f 100644 --- a/spec/inkmark_spec.rb +++ b/spec/inkmark_spec.rb @@ -941,6 +941,11 @@ expect(html).to include('loading="lazy"') end + it "treats an image nested in another image's alt text as alt text" do + md = described_class.to_markdown("![foo ![bar](/url)](/url2)", options: {images: {lazy: true}}) + expect(md).to eq(%(foo bar)) + end + it "escapes HTML specials in alt text" do html = described_class.to_html('![a"bd&e](img.png)', options: {images: {lazy: true}}) expect(html).to include(""") @@ -1082,6 +1087,46 @@ end end + context "in Markdown output by default" do + let(:source) { "
\n*inside*\n
\n\npara" } + + def live_div?(markdown) + described_class.to_html(markdown, options: {raw_html: true}).include?("<!-- foo -->") + expect(html).not_to match(/^