inkmark 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99be70c153a0446a98b513e0546389622cb5815d5907388f0b9160da7b1652cc
4
- data.tar.gz: b156f0536a351ef5780816a10ccedbb4d5a5a3a6c8741f2ccbbd065247f249ed
3
+ metadata.gz: 4a3feea6c75cbbc48daa868ce5aa140f1929df7b6b109ee83e6ed67c4b290351
4
+ data.tar.gz: ddddb3918c21fe25cf33f2b58370475c9e5f4a1a0b2963b32de904d9d096cf1f
5
5
  SHA512:
6
- metadata.gz: b7dcd628ed44d5c5ab757f257c95f2b23ad6880835f68db65f735ba45a6f7666de1bff0be3cc6e088ba18f3407848db0cc9c63d970e1a415bba1451ac58a15d7
7
- data.tar.gz: b704a73b47b7e85db26b13b1c02dc5a840939fea7eed13b9f4e7b68f816a23add8ed1bc60d3278656d3f4951dad318519ac14d257286ea513abbcf55dc7d622e
6
+ metadata.gz: 0a02d6c1b1f9067787cf880fbf044b59bc05220bc075d99332af7e0cac416f2bdcf2545b935d7d309db45411d67f01ab4f0614ed52f0ddc1f28bb261ddff892c
7
+ data.tar.gz: 242ecf84608b2de0fdd37e883506d3fbd0ec7cb1dc5184bbb707e1648a99e0ca4d3834ee48d74b21d69d90cf5c3922fb1c36d48754b521c858c20e00c3af9e17
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.3.1] - 2026-10-24
2
+
3
+ - Re-release of 0.3.0, which was yanked.
4
+ - 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.
5
+ - Fix raw HTML leaking into Markdown output with `raw_html: false` when a line held more than one tag.
6
+ - Fix `chunks_by_size` and `truncate_markdown` (`at: :block`) joining blocks without a blank line, which merged adjacent paragraphs.
7
+ - Fix Markdown output crashing on an image nested in another image's alt text with `images: { lazy: true }`, `allowed_hosts`, or `allowed_schemes`.
8
+ - `chunks_by_heading` starts sections only at top-level headings; a heading inside a block quote or list item no longer splits it.
9
+ - Raise `Inkmark::Error` on Markdown serialization failures instead of crashing the process.
10
+
11
+ ## [0.3.0] - 2026-10-24 [YANKED]
12
+
13
+ - Yanked: a RubyGems issue interrupted publishing, so only one platform gem was released. Its changes shipped in 0.3.1.
14
+
1
15
  ## [0.2.2] - 2026-10-24
2
16
 
3
17
  - Updated dependencies, including pulldown-cmark 0.13.5 (upstream security fixes) and magnus 0.9.3.
data/Cargo.lock CHANGED
@@ -333,7 +333,7 @@ dependencies = [
333
333
 
334
334
  [[package]]
335
335
  name = "inkmark"
336
- version = "0.2.2"
336
+ version = "0.3.1"
337
337
  dependencies = [
338
338
  "deunicode",
339
339
  "emojis",
@@ -527,11 +527,12 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
527
527
 
528
528
  [[package]]
529
529
  name = "pulldown-cmark-to-cmark"
530
- version = "22.0.3"
530
+ version = "23.0.0"
531
531
  source = "registry+https://github.com/rust-lang/crates.io-index"
532
- checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867"
532
+ checksum = "e9e14cd4ec725b2227ba76d4772169af814438bcf48f6d9b806d1308f40a7413"
533
533
  dependencies = [
534
534
  "pulldown-cmark",
535
+ "unicase",
535
536
  ]
536
537
 
537
538
  [[package]]
data/README.md CHANGED
@@ -206,6 +206,9 @@ Inkmark.to_html("<script>alert(1)</script>")
206
206
  # => "<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>\n"
207
207
  ```
208
208
 
209
+ Markdown output escapes it too; see
210
+ [Raw HTML in the source](#raw-html-in-the-source).
211
+
209
212
  Enable pass-through with `raw_html: true`; _only do this for trusted
210
213
  input_:
211
214
 
@@ -403,6 +406,12 @@ nested `###` subsections, which also appear as their own entries. Content
403
406
  before the first heading (if any) becomes a preamble entry with
404
407
  `heading: nil` and `level: 0`.
405
408
 
409
+ Only **top-level** headings start a section. A heading inside a block
410
+ quote or list item (such as a `## Tip` in a `> [!NOTE]` callout) stays in
411
+ the content of the section around it, so the quote or list is never split
412
+ across sections. It still counts toward slug deduplication, so every
413
+ section's `:id` matches the anchor `headings: { ids: true }` generates.
414
+
406
415
  `:breadcrumb` carries the ancestor heading texts from root to immediate
407
416
  parent. Root-level sections and the preamble have an empty array. Skipped
408
417
  levels are omitted, so an `###` directly under an `#` has `breadcrumb:
@@ -740,6 +749,17 @@ Inkmark.to_markdown(source, options: {
740
749
  })
741
750
  ```
742
751
 
752
+ ### Raw HTML in the source
753
+
754
+ With `raw_html: false` (the default), raw HTML from the source comes out of
755
+ `to_markdown`, `chunks_by_heading`, `chunks_by_size`, and `truncate_markdown`
756
+ escaped, so the Markdown stays inert even if a later stage renders it with raw
757
+ HTML allowed. HTML produced by the HTML-emitting filters above is the
758
+ exception: it is embedded as described.
759
+
760
+ If the filtered document cannot be written back as Markdown, these methods
761
+ raise `Inkmark::Error`.
762
+
743
763
  ## Event handlers
744
764
 
745
765
  Register handlers with `#on` to inspect or transform document elements as they
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "inkmark"
3
- version = "0.2.2"
3
+ version = "0.3.1"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -22,7 +22,7 @@ syntect = { version = "5.2", default-features = false, features = ["parsing", "h
22
22
  linkify = "0.11"
23
23
  globset = "0.4"
24
24
  url = "2"
25
- pulldown-cmark-to-cmark = "22"
25
+ pulldown-cmark-to-cmark = "23"
26
26
 
27
27
  [build-dependencies]
28
28
  rb-sys-env = "0.2.2"
@@ -14,6 +14,12 @@
14
14
  //! emoji/autolink rewrites happen *inside* a heading's text events,
15
15
  //! but the bracketing tags stay in place—so post-filter heading-
16
16
  //! position scanning is coherent.
17
+ //!
18
+ //! Only top-level headings start a section. A heading inside a block
19
+ //! quote, list item, or other container stays in the content of the
20
+ //! section around it, so a container is never split across sections.
21
+ //! Nested headings still consume slugs, keeping section ids aligned
22
+ //! with the ids `heading_ids` / `toc` emit.
17
23
 
18
24
  use magnus::{Error, RArray, RHash, Ruby};
19
25
  use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
@@ -21,6 +27,7 @@ use unicode_segmentation::UnicodeSegmentation;
21
27
 
22
28
  use crate::document::{apply_filters, content_events};
23
29
  use crate::heading::{self, SlugDeduplicator};
30
+ use crate::markdown;
24
31
  use crate::options::build_options;
25
32
  use crate::toc;
26
33
  use crate::truncate::{self, TruncateParams};
@@ -57,8 +64,13 @@ pub fn native_chunks_by_heading(
57
64
  let content_opts = ContentOptions {
58
65
  with_counts: flags.statistics,
59
66
  truncate_params: truncate_params.as_ref(),
67
+ md_opts: markdown::serializer_options(cm_opts, &flags),
60
68
  };
61
- let preamble_end = boundaries.first().map(|b| b.start).unwrap_or(events.len());
69
+ let preamble_end = boundaries
70
+ .iter()
71
+ .find(|b| b.top_level)
72
+ .map(|b| b.start)
73
+ .unwrap_or(events.len());
62
74
  if preamble_end > 0 {
63
75
  let preamble_events = &events[0..preamble_end];
64
76
  if !is_empty_content(preamble_events) {
@@ -78,23 +90,29 @@ pub fn native_chunks_by_heading(
78
90
  let mut dedup = SlugDeduplicator::new();
79
91
  let mut ancestors: Vec<(u8, String)> = Vec::new();
80
92
  for (i, boundary) in boundaries.iter().enumerate() {
93
+ let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
94
+ let id = heading_id(&mut dedup, &heading_text);
95
+ if !boundary.top_level {
96
+ // Stays in the surrounding section's content; only its slug
97
+ // counts, so later ids match `heading_ids`.
98
+ continue;
99
+ }
81
100
  let section_end = find_section_end(&boundaries, i, events.len());
82
101
  let level = toc::level_to_u8(boundary.level);
83
102
  while ancestors.last().is_some_and(|(l, _)| *l >= level) {
84
103
  ancestors.pop();
85
104
  }
86
105
  let breadcrumb: Vec<&str> = ancestors.iter().map(|(_, t)| t.as_str()).collect();
87
- let heading_text = collect_inline_text(&events[(boundary.start + 1)..boundary.end]);
88
106
  // Content = events after End(Heading) up to the next section or
89
- // end of document. Re-serialized through cmark_write.
107
+ // end of document. Re-serialized through `markdown::render`.
90
108
  let content_events = &events[(boundary.end + 1)..section_end];
91
109
  let hash = build_section_hash(
92
110
  ruby,
93
111
  boundary.level,
94
112
  &heading_text,
113
+ &id,
95
114
  &breadcrumb,
96
115
  content_events,
97
- &mut dedup,
98
116
  &content_opts,
99
117
  )?;
100
118
  result.push(hash)?;
@@ -110,6 +128,9 @@ struct HeadingBoundary {
110
128
  start: usize,
111
129
  end: usize,
112
130
  level: HeadingLevel,
131
+ /// Not inside a block quote, list item, or other container. Only
132
+ /// top-level headings start sections.
133
+ top_level: bool,
113
134
  }
114
135
 
115
136
  /// How each entry's `content` is rendered: optionally truncated, and
@@ -117,14 +138,20 @@ struct HeadingBoundary {
117
138
  struct ContentOptions<'a> {
118
139
  with_counts: bool,
119
140
  truncate_params: Option<&'a TruncateParams>,
141
+ /// Serializer options, from `markdown::serializer_options`.
142
+ md_opts: pulldown_cmark::Options,
120
143
  }
121
144
 
122
145
  fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
123
146
  let mut boundaries = Vec::new();
147
+ // Containers open around the cursor. Headings are leaf blocks, so
148
+ // none open at a Start(Heading) means it is not inside anything.
149
+ let mut open_containers = 0usize;
124
150
  let mut i = 0;
125
151
  while i < events.len() {
126
152
  if let Event::Start(Tag::Heading { level, .. }) = &events[i] {
127
153
  let lvl = *level;
154
+ let top_level = open_containers == 0;
128
155
  // CommonMark disallows headings inside headings, but carry a
129
156
  // depth counter so we stay correct if pulldown-cmark ever
130
157
  // permits them.
@@ -147,23 +174,29 @@ fn find_heading_boundaries(events: &[Event<'_>]) -> Vec<HeadingBoundary> {
147
174
  start: i,
148
175
  end: j,
149
176
  level: lvl,
177
+ top_level,
150
178
  });
151
179
  i = j + 1;
152
180
  } else {
181
+ match &events[i] {
182
+ Event::Start(_) => open_containers += 1,
183
+ Event::End(_) => open_containers = open_containers.saturating_sub(1),
184
+ _ => {}
185
+ }
153
186
  i += 1;
154
187
  }
155
188
  }
156
189
  boundaries
157
190
  }
158
191
 
159
- /// Section i ends at the first subsequent heading with level <=
160
- /// current. Headings with a strictly greater level are subsections:
192
+ /// Section i ends at the first subsequent top-level heading with level
193
+ /// <= current. Headings with a strictly greater level are subsections:
161
194
  /// they belong to the current section's content too.
162
195
  fn find_section_end(boundaries: &[HeadingBoundary], i: usize, events_len: usize) -> usize {
163
196
  let current = toc::level_to_u8(boundaries[i].level);
164
197
  boundaries[(i + 1)..]
165
198
  .iter()
166
- .find(|b| toc::level_to_u8(b.level) <= current)
199
+ .find(|b| b.top_level && toc::level_to_u8(b.level) <= current)
167
200
  .map(|b| b.start)
168
201
  .unwrap_or(events_len)
169
202
  }
@@ -181,10 +214,7 @@ fn build_preamble_hash(
181
214
  // with proper sections so callers can treat every entry alike.
182
215
  hash.aset(ruby.to_symbol("breadcrumb"), ruby.ary_new_capa(0))?;
183
216
 
184
- let content = match content_opts.truncate_params {
185
- Some(params) => truncate::truncate_events(events, params),
186
- None => render_markdown(events),
187
- };
217
+ let content = render_content(ruby, events, content_opts)?;
188
218
  if content_opts.with_counts {
189
219
  let (chars, words) = count_post_truncate(events, content_opts.truncate_params, &content);
190
220
  hash.aset(ruby.to_symbol("character_count"), chars)?;
@@ -198,21 +228,11 @@ fn build_section_hash(
198
228
  ruby: &Ruby,
199
229
  level: HeadingLevel,
200
230
  heading_text: &str,
231
+ id: &str,
201
232
  breadcrumb: &[&str],
202
233
  content_events: &[Event<'_>],
203
- dedup: &mut SlugDeduplicator,
204
234
  content_opts: &ContentOptions<'_>,
205
235
  ) -> Result<RHash, Error> {
206
- // Slug is the deduplicated slugify of the (filter-applied) heading
207
- // text, matching the ids `heading_ids` / `toc` would emit for the
208
- // same document.
209
- let base = heading::slugify(heading_text);
210
- let id = if base.is_empty() {
211
- String::new()
212
- } else {
213
- dedup.deduplicate(base)
214
- };
215
-
216
236
  let hash = ruby.hash_new();
217
237
  hash.aset(ruby.to_symbol("heading"), heading_text)?;
218
238
  hash.aset(ruby.to_symbol("level"), toc::level_to_u8(level))?;
@@ -227,10 +247,7 @@ fn build_section_hash(
227
247
  }
228
248
  hash.aset(ruby.to_symbol("breadcrumb"), breadcrumb_arr)?;
229
249
 
230
- let content = match content_opts.truncate_params {
231
- Some(params) => truncate::truncate_events(content_events, params),
232
- None => render_markdown(content_events),
233
- };
250
+ let content = render_content(ruby, content_events, content_opts)?;
234
251
  if content_opts.with_counts {
235
252
  let (chars, words) =
236
253
  count_post_truncate(content_events, content_opts.truncate_params, &content);
@@ -289,11 +306,24 @@ fn count_text(events: &[Event<'_>]) -> (usize, usize) {
289
306
  (chars, words)
290
307
  }
291
308
 
292
- fn render_markdown(events: &[Event<'_>]) -> String {
293
- let mut buf = String::new();
294
- pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf)
295
- .expect("markdown serialization failed");
296
- buf
309
+ /// Deduplicated slug of the (filter-applied) heading text, matching the
310
+ /// ids `heading_ids` / `toc` would emit for the same document. Empty when
311
+ /// the text has nothing to slugify.
312
+ fn heading_id(dedup: &mut SlugDeduplicator, heading_text: &str) -> String {
313
+ dedup.deduplicate(heading::slugify(heading_text))
314
+ }
315
+
316
+ /// Serialize a section's events to Markdown, truncated when requested.
317
+ fn render_content(
318
+ ruby: &Ruby,
319
+ events: &[Event<'_>],
320
+ content_opts: &ContentOptions<'_>,
321
+ ) -> Result<String, Error> {
322
+ match content_opts.truncate_params {
323
+ Some(params) => truncate::truncate_events(events, params, content_opts.md_opts),
324
+ None => markdown::render(events, content_opts.md_opts),
325
+ }
326
+ .map_err(|e| markdown::to_ruby_error(ruby, e))
297
327
  }
298
328
 
299
329
  fn collect_inline_text(events: &[Event<'_>]) -> String {
@@ -324,3 +354,33 @@ fn is_empty_content(events: &[Event<'_>]) -> bool {
324
354
  )
325
355
  })
326
356
  }
357
+
358
+ #[cfg(test)]
359
+ mod tests {
360
+ use super::*;
361
+ use pulldown_cmark::Options;
362
+
363
+ fn events(source: &str) -> Vec<Event<'_>> {
364
+ Parser::new_ext(source, Options::empty()).collect()
365
+ }
366
+
367
+ #[test]
368
+ fn headings_inside_containers_are_not_top_level() {
369
+ let ev = events("# Top\n\n> ## Quoted\n\n- # Listed\n\n## Sub\n");
370
+ let top_level: Vec<bool> = find_heading_boundaries(&ev)
371
+ .iter()
372
+ .map(|b| b.top_level)
373
+ .collect();
374
+ assert_eq!(top_level, vec![true, false, false, true]);
375
+ }
376
+
377
+ #[test]
378
+ fn section_spanning_a_nested_heading_stays_balanced() {
379
+ let ev = events("# Top\n\n> ## Inner\n> text\n\nafter\n");
380
+ let boundaries = find_heading_boundaries(&ev);
381
+ let end = find_section_end(&boundaries, 0, ev.len());
382
+ let content = &ev[(boundaries[0].end + 1)..end];
383
+ let md = markdown::render(content, Options::empty()).expect("balanced slice");
384
+ assert!(md.contains("Inner") && md.contains("after"), "{md:?}");
385
+ }
386
+ }
@@ -22,6 +22,7 @@ use pulldown_cmark::Event;
22
22
  use unicode_segmentation::UnicodeSegmentation;
23
23
 
24
24
  use crate::document::{apply_filters, content_events};
25
+ use crate::markdown::{self, SerializeError};
25
26
  use crate::options::build_options;
26
27
 
27
28
  #[derive(Clone, Copy, PartialEq, Eq)]
@@ -48,10 +49,12 @@ pub fn native_chunks_by_size(
48
49
  let events: Vec<Event> = content_events(&source, cm_opts).collect();
49
50
  let events = apply_filters(events, &flags);
50
51
 
52
+ let md_opts = markdown::serializer_options(cm_opts, &flags);
51
53
  let windows = match params.at {
52
- BoundaryAt::Block => chunk_blocks(&events, &params),
53
- BoundaryAt::Word => chunk_words(&events, &params),
54
- };
54
+ BoundaryAt::Block => chunk_blocks(&events, &params, md_opts),
55
+ BoundaryAt::Word => chunk_words(&events, &params, md_opts),
56
+ }
57
+ .map_err(|e| markdown::to_ruby_error(ruby, e))?;
55
58
 
56
59
  build_result(ruby, &windows, flags.statistics)
57
60
  }
@@ -79,20 +82,30 @@ fn parse_params(ruby: &Ruby, hash: &RHash) -> Result<WindowParams, Error> {
79
82
  })
80
83
  }
81
84
 
82
- fn chunk_blocks(events: &[Event<'_>], params: &WindowParams) -> Vec<String> {
85
+ fn chunk_blocks(
86
+ events: &[Event<'_>],
87
+ params: &WindowParams,
88
+ md_opts: pulldown_cmark::Options,
89
+ ) -> Result<Vec<String>, SerializeError> {
83
90
  let mut out: Vec<String> = Vec::new();
84
91
  let mut current = String::new();
85
92
  let mut current_chars: usize = 0;
86
93
  let mut current_words: usize = 0;
87
94
 
88
95
  for (start, end) in top_level_blocks(events) {
89
- let block = render_markdown(&events[start..=end]);
96
+ let block = markdown::render_block(&events[start..=end], md_opts)?;
90
97
  let block_chars = block.chars().count();
91
98
  let block_words = block.unicode_words().count();
99
+ // The blank line rejoining blocks counts toward the char budget.
100
+ let separator_chars = if current.is_empty() {
101
+ 0
102
+ } else {
103
+ markdown::BLOCK_SEPARATOR.len()
104
+ };
92
105
 
93
106
  let would_exceed_chars = params
94
107
  .chars
95
- .map(|b| current_chars + block_chars > b)
108
+ .map(|b| current_chars + separator_chars + block_chars > b)
96
109
  .unwrap_or(false);
97
110
  let would_exceed_words = params
98
111
  .words
@@ -110,6 +123,11 @@ fn chunk_blocks(events: &[Event<'_>], params: &WindowParams) -> Vec<String> {
110
123
  out.push(finished);
111
124
  }
112
125
 
126
+ // Re-checked: a flush leaves `current` holding the overlap seed.
127
+ if !current.is_empty() {
128
+ current.push_str(markdown::BLOCK_SEPARATOR);
129
+ current_chars += markdown::BLOCK_SEPARATOR.len();
130
+ }
113
131
  current.push_str(&block);
114
132
  current_chars += block_chars;
115
133
  current_words += block_words;
@@ -118,7 +136,7 @@ fn chunk_blocks(events: &[Event<'_>], params: &WindowParams) -> Vec<String> {
118
136
  if !current.is_empty() {
119
137
  out.push(current);
120
138
  }
121
- out
139
+ Ok(out)
122
140
  }
123
141
 
124
142
  /// Return the trailing `overlap` characters of `s`, aligned to a
@@ -167,13 +185,6 @@ fn top_level_blocks(events: &[Event<'_>]) -> Vec<(usize, usize)> {
167
185
  blocks
168
186
  }
169
187
 
170
- fn render_markdown(events: &[Event<'_>]) -> String {
171
- let mut buf = String::new();
172
- pulldown_cmark_to_cmark::cmark(events.iter().cloned(), &mut buf)
173
- .expect("markdown serialization failed");
174
- buf
175
- }
176
-
177
188
  // Serialize the full filtered Markdown into one string, then walk
178
189
  // word boundaries. Each window is a byte-aligned slice of the
179
190
  // serialized output ending at a word boundary that fits the budget.
@@ -181,10 +192,14 @@ fn render_markdown(events: &[Event<'_>]) -> String {
181
192
  // `overlap` chars (to the next word boundary) before taking the
182
193
  // next window.
183
194
 
184
- fn chunk_words(events: &[Event<'_>], params: &WindowParams) -> Vec<String> {
185
- let rendered = render_markdown(events);
195
+ fn chunk_words(
196
+ events: &[Event<'_>],
197
+ params: &WindowParams,
198
+ md_opts: pulldown_cmark::Options,
199
+ ) -> Result<Vec<String>, SerializeError> {
200
+ let rendered = markdown::render(events, md_opts)?;
186
201
  if rendered.is_empty() {
187
- return Vec::new();
202
+ return Ok(Vec::new());
188
203
  }
189
204
 
190
205
  let mut out: Vec<String> = Vec::new();
@@ -250,7 +265,7 @@ fn chunk_words(events: &[Event<'_>], params: &WindowParams) -> Vec<String> {
250
265
  };
251
266
  }
252
267
 
253
- out
268
+ Ok(out)
254
269
  }
255
270
 
256
271
  /// Step the cursor back by roughly `overlap` characters, then land on
@@ -7,6 +7,7 @@ use crate::heading;
7
7
  use crate::highlight;
8
8
  use crate::image;
9
9
  use crate::link;
10
+ use crate::markdown::{self, SerializeError};
10
11
  use crate::options::{build_options, Flags};
11
12
  use crate::plain_text;
12
13
  use crate::scheme_filter::SchemeFilter;
@@ -36,13 +37,14 @@ pub fn native_to_markdown(
36
37
  source: String,
37
38
  opts_hash: Option<RHash>,
38
39
  ) -> Result<String, Error> {
39
- match opts_hash {
40
- None => Ok(markdown_defaults(&source)),
40
+ let rendered = match opts_hash {
41
+ None => markdown_defaults(&source),
41
42
  Some(hash) => {
42
43
  let (cm_opts, flags) = build_options(ruby, hash)?;
43
- Ok(render_to_markdown(&source, cm_opts, flags))
44
+ render_to_markdown(&source, cm_opts, flags)
44
45
  }
45
- }
46
+ };
47
+ rendered.map_err(|e| markdown::to_ruby_error(ruby, e))
46
48
  }
47
49
 
48
50
  pub fn native_to_plain_text(
@@ -71,12 +73,13 @@ fn render_defaults(source: &str) -> String {
71
73
  }
72
74
 
73
75
  /// Same as render_defaults but serializes to Markdown instead of HTML.
74
- fn markdown_defaults(source: &str) -> String {
76
+ fn markdown_defaults(source: &str) -> Result<String, SerializeError> {
75
77
  let mut buf = String::with_capacity(source.len());
76
- let parser = Parser::new_ext(source, default_cm_opts());
78
+ let cm_opts = default_cm_opts();
79
+ let parser = Parser::new_ext(source, cm_opts);
77
80
  let filtered = parser.map(suppress_raw_html);
78
- cmark_write(filtered, &mut buf);
79
- buf
81
+ markdown::write(filtered, &mut buf, cm_opts)?;
82
+ Ok(buf)
80
83
  }
81
84
 
82
85
  /// 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
255
258
  buf
256
259
  }
257
260
 
258
- fn render_to_markdown(source: &str, cm_opts: pulldown_cmark::Options, flags: Flags) -> String {
259
- let mut buf = String::with_capacity(source.len());
260
-
261
+ fn render_to_markdown(
262
+ source: &str,
263
+ cm_opts: pulldown_cmark::Options,
264
+ flags: Flags,
265
+ ) -> Result<String, SerializeError> {
261
266
  // `content_events` strips frontmatter, so the cmark serializer never sees
262
267
  // a metadata block to re-emit—on either the streaming or buffered path.
263
268
  if !needs_buffer(&flags) {
264
- cmark_write(
269
+ let mut buf = String::with_capacity(source.len());
270
+ markdown::write(
265
271
  content_events(source, cm_opts).map(stream_filter(&flags)),
266
272
  &mut buf,
267
- );
268
- return buf;
273
+ cm_opts,
274
+ )?;
275
+ return Ok(buf);
269
276
  }
270
277
 
271
278
  let events = apply_filters(content_events(source, cm_opts).collect(), &flags);
272
- cmark_write(events.into_iter(), &mut buf);
273
- buf
279
+ markdown::render(&events, markdown::serializer_options(cm_opts, &flags))
274
280
  }
275
281
 
276
282
  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<Event<'a>>, flags: &Flags)
447
453
 
448
454
  events
449
455
  }
450
-
451
- fn cmark_write<'a, I: Iterator<Item = Event<'a>>>(events: I, buf: &mut String) {
452
- pulldown_cmark_to_cmark::cmark(events, buf).expect("markdown serialization failed");
453
- }