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`). 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))
+ end
+
it "escapes HTML specials in alt text" do
html = described_class.to_html('![a"b
<!-- foo -->")
+ expect(html).not_to match(/^