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.
@@ -1,7 +1,7 @@
1
1
  //! Image attribute injection filter and URL matcher.
2
2
  //!
3
3
  //! When enabled, replaces pulldown-cmark's default image event sequence
4
- //! (`Start(Tag::Image) ... End(TagEnd::Image)`) with a single `Event::Html`
4
+ //! (`Start(Tag::Image) ... End(TagEnd::Image)`) with a single `Event::InlineHtml`
5
5
  //! carrying a hand-built `<img>` tag that includes the "modern" loading and
6
6
  //! decoding hints:
7
7
  //!
@@ -23,7 +23,7 @@ use pulldown_cmark_escape::{escape_href, escape_html};
23
23
 
24
24
  use crate::url_match::is_host_allowed;
25
25
 
26
- /// Rewrite every image in the event stream as a self-contained `Event::Html`
26
+ /// Rewrite every image in the event stream as a self-contained `Event::InlineHtml`
27
27
  /// carrying `<img ... loading="lazy" decoding="async">`.
28
28
  ///
29
29
  /// We consume the input Vec to own each event, then rebuild with
@@ -38,22 +38,9 @@ pub fn add_lazy_loading(events: Vec<Event<'_>>) -> Vec<Event<'_>> {
38
38
  Event::Start(Tag::Image {
39
39
  dest_url, title, ..
40
40
  }) => {
41
- // Consume events up to the matching End(Image), accumulating
42
- // alt text from Text and Code payloads. Images can contain
43
- // inline formatting (e.g. `![**bold**](img.png)`), which
44
- // produces Start(Strong)/Text/End(Strong) events; the bare
45
- // text content is what we want for the alt attribute.
46
- let mut alt = String::new();
47
- for inner in iter.by_ref() {
48
- match inner {
49
- Event::End(TagEnd::Image) => break,
50
- Event::Text(t) | Event::Code(t) => alt.push_str(&t),
51
- _ => {}
52
- }
53
- }
54
-
41
+ let alt = take_alt_text(&mut iter);
55
42
  let html = build_img_tag(&dest_url, &alt, &title);
56
- out.push(Event::Html(CowStr::Boxed(html.into_boxed_str())));
43
+ out.push(Event::InlineHtml(CowStr::Boxed(html.into_boxed_str())));
57
44
  }
58
45
  other => out.push(other),
59
46
  }
@@ -68,10 +55,8 @@ pub fn add_lazy_loading(events: Vec<Event<'_>>) -> Vec<Event<'_>> {
68
55
  /// when alt is empty. Non-web URLs pass through: [`is_host_allowed`]
69
56
  /// returns true for any URL with no parseable host.
70
57
  ///
71
- /// Alt accumulation matches `add_lazy_loading`: images can contain
72
- /// markdown like `![**bold**](img.png)`, producing
73
- /// Start(Strong)/Text/End(Strong) events—we pull the raw text payloads
74
- /// out and discard formatting.
58
+ /// Alt accumulation is shared with `add_lazy_loading` (see
59
+ /// [`take_alt_text`]).
75
60
  pub fn filter_by_hosts<'a>(events: Vec<Event<'a>>, set: &GlobSet) -> Vec<Event<'a>> {
76
61
  let mut out: Vec<Event<'a>> = Vec::with_capacity(events.len());
77
62
  let mut iter = events.into_iter();
@@ -79,14 +64,7 @@ pub fn filter_by_hosts<'a>(events: Vec<Event<'a>>, set: &GlobSet) -> Vec<Event<'
79
64
  while let Some(event) = iter.next() {
80
65
  match event {
81
66
  Event::Start(Tag::Image { ref dest_url, .. }) if !is_host_allowed(dest_url, set) => {
82
- let mut alt = String::new();
83
- for inner in iter.by_ref() {
84
- match inner {
85
- Event::End(TagEnd::Image) => break,
86
- Event::Text(t) | Event::Code(t) => alt.push_str(&t),
87
- _ => {}
88
- }
89
- }
67
+ let alt = take_alt_text(&mut iter);
90
68
  if !alt.is_empty() {
91
69
  out.push(Event::Text(CowStr::Boxed(alt.into_boxed_str())));
92
70
  }
@@ -98,6 +76,30 @@ pub fn filter_by_hosts<'a>(events: Vec<Event<'a>>, set: &GlobSet) -> Vec<Event<'
98
76
  out
99
77
  }
100
78
 
79
+ /// Consume events through the `End(Image)` matching an already-consumed
80
+ /// `Start(Image)` and return the alt text: the raw Text and Code payloads,
81
+ /// with formatting (`![**bold**](img.png)`) discarded. An image nested in
82
+ /// the alt text (`![a ![b](x)](y)`) contributes its text too, so depth is
83
+ /// counted to stop at the matching End rather than the nested image's.
84
+ fn take_alt_text<'a>(iter: &mut impl Iterator<Item = Event<'a>>) -> String {
85
+ let mut alt = String::new();
86
+ let mut depth: usize = 1;
87
+ for inner in iter {
88
+ match inner {
89
+ Event::Start(Tag::Image { .. }) => depth += 1,
90
+ Event::End(TagEnd::Image) => {
91
+ depth -= 1;
92
+ if depth == 0 {
93
+ break;
94
+ }
95
+ }
96
+ Event::Text(t) | Event::Code(t) => alt.push_str(&t),
97
+ _ => {}
98
+ }
99
+ }
100
+ alt
101
+ }
102
+
101
103
  /// Construct the `<img>` HTML string with `loading="lazy"` and
102
104
  /// `decoding="async"` attributes. `src` is escaped as a URL (percent-
103
105
  /// encoded where necessary); `alt` and `title` are HTML-attribute
@@ -201,7 +203,7 @@ mod tests {
201
203
  let out = add_lazy_loading(events);
202
204
  assert_eq!(out.len(), 1, "should collapse to one event");
203
205
  match &out[0] {
204
- Event::Html(html) => {
206
+ Event::InlineHtml(html) => {
205
207
  assert!(
206
208
  html.contains("loading="),
207
209
  "missing loading attribute: {html}"
@@ -233,6 +235,31 @@ mod tests {
233
235
  }
234
236
  }
235
237
 
238
+ fn has_image_event(events: &[Event<'_>]) -> bool {
239
+ events.iter().any(|e| {
240
+ matches!(
241
+ e,
242
+ Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image)
243
+ )
244
+ })
245
+ }
246
+
247
+ #[test]
248
+ fn image_nested_in_alt_text_is_consumed_with_its_parent() {
249
+ let source = "![foo ![bar](https://evil.com/1)](https://evil.com/2)";
250
+ let events: Vec<Event> = pulldown_cmark::Parser::new(source).collect();
251
+
252
+ let dropped = filter_by_hosts(events.clone(), &host_set(&["example.net"]));
253
+ assert!(!has_image_event(&dropped), "stray image event: {dropped:?}");
254
+ assert!(dropped.contains(&Event::Text(CowStr::Borrowed("foo bar"))));
255
+
256
+ let lazy = add_lazy_loading(events);
257
+ assert!(!has_image_event(&lazy), "stray image event: {lazy:?}");
258
+ assert!(lazy
259
+ .iter()
260
+ .any(|e| matches!(e, Event::InlineHtml(h) if h.contains("alt=\"foo bar\""))));
261
+ }
262
+
236
263
  #[test]
237
264
  fn filter_by_hosts_drops_disallowed_image_with_empty_alt_entirely() {
238
265
  let events = vec![
@@ -14,6 +14,7 @@ mod heading;
14
14
  mod highlight;
15
15
  mod image;
16
16
  mod link;
17
+ mod markdown;
17
18
  mod options;
18
19
  mod plain_text;
19
20
  mod scheme_filter;
@@ -18,8 +18,9 @@ use pulldown_cmark_escape::{escape_href, escape_html};
18
18
  use crate::url_match::is_host_allowed;
19
19
 
20
20
  /// Add `rel="nofollow noopener"` to every external `<a>` tag by replacing
21
- /// its `Start(Link)` event with a synthesized `Event::Html` opening tag
22
- /// and its matching `End(Link)` event with a `</a>` close tag.
21
+ /// its `Start(Link)` event with a synthesized `Event::InlineHtml` opening
22
+ /// tag and its matching `End(Link)` event with a `</a>` close tag. Inline,
23
+ /// not block, HTML: the Markdown serializer needs to know it sits in a line.
23
24
  pub fn add_nofollow(events: Vec<Event<'_>>) -> Vec<Event<'_>> {
24
25
  let mut out: Vec<Event<'_>> = Vec::with_capacity(events.len());
25
26
  let mut iter = events.into_iter();
@@ -33,7 +34,7 @@ pub fn add_nofollow(events: Vec<Event<'_>>) -> Vec<Event<'_>> {
33
34
  id: _,
34
35
  }) if is_external(dest_url) => {
35
36
  let open = build_link_open(dest_url, title);
36
- out.push(Event::Html(CowStr::Boxed(open.into_boxed_str())));
37
+ out.push(Event::InlineHtml(CowStr::Boxed(open.into_boxed_str())));
37
38
 
38
39
  // Consume inner events through the matching End(Link),
39
40
  // depth-counting so a nested link doesn't break the
@@ -51,7 +52,7 @@ pub fn add_nofollow(events: Vec<Event<'_>>) -> Vec<Event<'_>> {
51
52
  } else if is_link_end {
52
53
  depth -= 1;
53
54
  if depth == 0 {
54
- out.push(Event::Html(CowStr::Borrowed("</a>")));
55
+ out.push(Event::InlineHtml(CowStr::Borrowed("</a>")));
55
56
  break;
56
57
  }
57
58
  out.push(inner);
@@ -209,10 +210,10 @@ mod tests {
209
210
  Event::End(TagEnd::Link),
210
211
  ];
211
212
  let out = add_nofollow(events);
212
- // Should produce: Html(open), Text("click"), Html("</a>")
213
+ // Should produce: InlineHtml(open), Text("click"), InlineHtml("</a>")
213
214
  assert_eq!(out.len(), 3, "expected 3 events, got {}", out.len());
214
215
  match &out[0] {
215
- Event::Html(html) => {
216
+ Event::InlineHtml(html) => {
216
217
  assert!(
217
218
  html.contains("nofollow"),
218
219
  "opening tag must contain nofollow: {html}"
@@ -222,11 +223,11 @@ mod tests {
222
223
  "opening tag must contain href: {html}"
223
224
  );
224
225
  }
225
- other => panic!("expected Html open event, got {other:?}"),
226
+ other => panic!("expected InlineHtml open event, got {other:?}"),
226
227
  }
227
228
  match &out[2] {
228
- Event::Html(html) => assert_eq!(html.as_ref(), "</a>"),
229
- other => panic!("expected Html close event, got {other:?}"),
229
+ Event::InlineHtml(html) => assert_eq!(html.as_ref(), "</a>"),
230
+ other => panic!("expected InlineHtml close event, got {other:?}"),
230
231
  }
231
232
  }
232
233
 
@@ -0,0 +1,436 @@
1
+ //! Markdown serialization through `pulldown-cmark-to-cmark`.
2
+ //!
3
+ //! Every Markdown-emitting API (`to_markdown`, `chunks_by_heading`,
4
+ //! `chunks_by_size`, `truncate_markdown`) funnels through this module so
5
+ //! the serializer always sees the parser options that produced the
6
+ //! events: pulldown-cmark-to-cmark uses them to pick escapes that reparse
7
+ //! to the same document.
8
+ //!
9
+ //! Serializer failures come back as `Err` instead of panicking (a panic
10
+ //! reaches Ruby as `fatal`, which cannot be rescued). Native entry points
11
+ //! turn them into `Inkmark::Error` via [`to_ruby_error`].
12
+ //!
13
+ //! Two entry points: [`write`] streams parser output straight through
14
+ //! (the unfiltered fast path); [`render`] takes filtered events and falls
15
+ //! back to [`tidy_inline_edges`] when the serializer rejects them.
16
+
17
+ use std::collections::VecDeque;
18
+
19
+ use magnus::{prelude::*, Error, ExceptionClass, RClass, Ruby};
20
+ use pulldown_cmark::{CowStr, Event, Options, Tag, TagEnd};
21
+
22
+ use crate::options::Flags;
23
+
24
+ pub use pulldown_cmark_to_cmark::Error as SerializeError;
25
+
26
+ /// Options to serialize a filtered document with: the parser options it
27
+ /// was parsed with, plus what the filters' output needs. The `heading_ids`
28
+ /// filter gives headings an `id`, which Markdown can only express as a
29
+ /// `{ #id }` heading attribute, so heading attributes are switched on for
30
+ /// the serializer (matching 0.2's output) instead of it failing on
31
+ /// headings it cannot otherwise preserve.
32
+ pub fn serializer_options(cm_opts: Options, flags: &Flags) -> Options {
33
+ if flags.heading_ids {
34
+ cm_opts | Options::ENABLE_HEADING_ATTRIBUTES
35
+ } else {
36
+ cm_opts
37
+ }
38
+ }
39
+
40
+ /// Serialize unfiltered parser output into `buf`. `md_opts` must be the
41
+ /// options the events were parsed with.
42
+ pub fn write<'a, I>(events: I, buf: &mut String, md_opts: Options) -> Result<(), SerializeError>
43
+ where
44
+ I: Iterator<Item = Event<'a>>,
45
+ {
46
+ pulldown_cmark_to_cmark::cmark(InertHtmlBlocks::new(events), buf, md_opts)?;
47
+ Ok(())
48
+ }
49
+
50
+ /// Serialize filtered events into a new String. `md_opts` comes from
51
+ /// [`serializer_options`].
52
+ ///
53
+ /// Filters can leave shapes the serializer cannot write (see
54
+ /// [`tidy_inline_edges`]). Parser output never has them, so events are
55
+ /// reshaped only after a rejection, then serialized once more: output that
56
+ /// serializes as-is is never touched.
57
+ pub fn render(events: &[Event<'_>], md_opts: Options) -> Result<String, SerializeError> {
58
+ let mut buf = String::new();
59
+ if write(events.iter().cloned(), &mut buf, md_opts).is_ok() {
60
+ return Ok(buf);
61
+ }
62
+ let mut events = events.to_vec();
63
+ tidy_inline_edges(&mut events);
64
+ let mut buf = String::new();
65
+ write(events.into_iter(), &mut buf, md_opts)?;
66
+ Ok(buf)
67
+ }
68
+
69
+ /// Blank line that rejoins top-level blocks serialized one at a time.
70
+ /// Without it, adjacent paragraphs would merge on reparse.
71
+ pub const BLOCK_SEPARATOR: &str = "\n\n";
72
+
73
+ /// Serialize one top-level block for joining with [`BLOCK_SEPARATOR`]:
74
+ /// rendered alone, minus the newlines it starts or ends with.
75
+ pub fn render_block(events: &[Event<'_>], md_opts: Options) -> Result<String, SerializeError> {
76
+ Ok(render(events, md_opts)?.trim_matches('\n').to_owned())
77
+ }
78
+
79
+ /// Convert a serializer failure into an `Inkmark::Error` exception.
80
+ ///
81
+ /// The class is looked up on each call rather than cached: the extension
82
+ /// keeps no Ruby `VALUE`s in process-global state (see `rb_ext_ractor_safe`
83
+ /// in `lib.rs`), and this path only runs on failure.
84
+ pub fn to_ruby_error(ruby: &Ruby, err: SerializeError) -> Error {
85
+ let class = ruby
86
+ .class_object()
87
+ .const_get::<_, RClass>("Inkmark")
88
+ .and_then(|inkmark| inkmark.const_get::<_, ExceptionClass>("Error"));
89
+ match class {
90
+ Ok(class) => Error::new(class, format!("markdown serialization failed: {err}")),
91
+ Err(lookup_failed) => lookup_failed,
92
+ }
93
+ }
94
+
95
+ /// Inline containers written as delimiter runs (`*`, `**`, `~~`, `^`, `~`).
96
+ /// CommonMark only recognizes a run that hugs non-whitespace, so these
97
+ /// cannot be empty or start or end with whitespace or a line break.
98
+ fn is_delimited_start(event: Option<&Event<'_>>) -> bool {
99
+ matches!(
100
+ event,
101
+ Some(Event::Start(
102
+ Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Superscript | Tag::Subscript
103
+ ))
104
+ )
105
+ }
106
+
107
+ fn is_delimited_end(end: &TagEnd) -> bool {
108
+ matches!(
109
+ end,
110
+ TagEnd::Emphasis
111
+ | TagEnd::Strong
112
+ | TagEnd::Strikethrough
113
+ | TagEnd::Superscript
114
+ | TagEnd::Subscript
115
+ )
116
+ }
117
+
118
+ /// Block containers: a line break cannot open or close one.
119
+ fn is_block_start(event: Option<&Event<'_>>) -> bool {
120
+ matches!(event, Some(Event::Start(tag)) if !matches!(tag,
121
+ Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Superscript
122
+ | Tag::Subscript | Tag::Link { .. } | Tag::Image { .. }))
123
+ }
124
+
125
+ fn is_block_end(end: &TagEnd) -> bool {
126
+ !is_delimited_end(end) && !matches!(end, TagEnd::Link | TagEnd::Image)
127
+ }
128
+
129
+ fn is_break(event: Option<&Event<'_>>) -> bool {
130
+ matches!(event, Some(Event::SoftBreak | Event::HardBreak))
131
+ }
132
+
133
+ /// What the parser strips at both ends of every line of inline content.
134
+ fn is_line_space(c: char) -> bool {
135
+ c == ' ' || c == '\t'
136
+ }
137
+
138
+ /// A line of inline content starts after this event.
139
+ fn opens_line(event: Option<&Event<'_>>) -> bool {
140
+ is_break(event)
141
+ || matches!(
142
+ event,
143
+ Some(Event::Start(
144
+ Tag::Paragraph | Tag::Heading { .. } | Tag::TableCell | Tag::Item
145
+ ))
146
+ )
147
+ }
148
+
149
+ /// A line of inline content ends at this tag.
150
+ fn closes_line(end: &TagEnd) -> bool {
151
+ matches!(
152
+ end,
153
+ TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::TableCell | TagEnd::Item
154
+ )
155
+ }
156
+
157
+ fn last_text_ends_with_line_space(out: &[Event<'_>]) -> bool {
158
+ matches!(out.last(), Some(Event::Text(t)) if t.ends_with(is_line_space))
159
+ }
160
+
161
+ fn trim_last_text_end(out: &mut Vec<Event<'_>>) {
162
+ if let Some(Event::Text(text)) = out.pop() {
163
+ let body = text.trim_end_matches(is_line_space);
164
+ if !body.is_empty() {
165
+ out.push(Event::Text(CowStr::from(body.to_owned())));
166
+ }
167
+ }
168
+ }
169
+
170
+ /// Reshape filtered events the serializer could not otherwise write.
171
+ ///
172
+ /// Filters can leave a delimited container with whitespace at an edge or
173
+ /// with nothing inside, typically by unwrapping a disallowed link or image
174
+ /// so its text lands directly inside `**...**`. Line breaks and whitespace
175
+ /// at an edge move outside the container, emptied containers are dropped,
176
+ /// a line break left opening or closing a block is dropped, and spaces or
177
+ /// tabs left at the start or end of a line are trimmed. Rendering is
178
+ /// unchanged (`<strong>a </strong>` displays like `<strong>a</strong> `).
179
+ fn tidy_inline_edges(events: &mut Vec<Event<'_>>) {
180
+ // Each pass fixes one nesting level per edge; repeat until stable.
181
+ // Every change shortens the list or moves an event strictly outward,
182
+ // so this terminates.
183
+ while tidy_pass(events) {}
184
+ }
185
+
186
+ fn tidy_pass(events: &mut Vec<Event<'_>>) -> bool {
187
+ let mut changed = false;
188
+ let mut out: Vec<Event<'_>> = Vec::with_capacity(events.len());
189
+ for event in events.drain(..) {
190
+ match event {
191
+ Event::End(end) if is_delimited_end(&end) && is_delimited_start(out.last()) => {
192
+ out.pop();
193
+ changed = true;
194
+ }
195
+ Event::End(end) if is_delimited_end(&end) && is_break(out.last()) => {
196
+ let brk = out.pop().expect("checked non-empty");
197
+ out.push(Event::End(end));
198
+ out.push(brk);
199
+ changed = true;
200
+ }
201
+ Event::End(end)
202
+ if is_delimited_end(&end)
203
+ && matches!(out.last(), Some(Event::Text(t)) if t.ends_with(char::is_whitespace)) =>
204
+ {
205
+ let Some(Event::Text(text)) = out.pop() else {
206
+ unreachable!("checked above")
207
+ };
208
+ let body = text.trim_end();
209
+ let space = text[body.len()..].to_owned();
210
+ if !body.is_empty() {
211
+ out.push(Event::Text(CowStr::from(body.to_owned())));
212
+ }
213
+ out.push(Event::End(end));
214
+ out.push(Event::Text(CowStr::from(space)));
215
+ changed = true;
216
+ }
217
+ Event::End(end) if is_block_end(&end) && is_break(out.last()) => {
218
+ out.pop();
219
+ out.push(Event::End(end));
220
+ changed = true;
221
+ }
222
+ Event::SoftBreak | Event::HardBreak if is_delimited_start(out.last()) => {
223
+ let start = out.pop().expect("checked non-empty");
224
+ out.push(event);
225
+ out.push(start);
226
+ changed = true;
227
+ }
228
+ Event::SoftBreak | Event::HardBreak if is_block_start(out.last()) => {
229
+ changed = true;
230
+ }
231
+ Event::Text(text)
232
+ if text.starts_with(char::is_whitespace) && is_delimited_start(out.last()) =>
233
+ {
234
+ let start = out.pop().expect("checked non-empty");
235
+ let body = text.trim_start();
236
+ let space = text[..text.len() - body.len()].to_owned();
237
+ out.push(Event::Text(CowStr::from(space)));
238
+ out.push(start);
239
+ if !body.is_empty() {
240
+ out.push(Event::Text(CowStr::from(body.to_owned())));
241
+ }
242
+ changed = true;
243
+ }
244
+ Event::End(end) if closes_line(&end) && last_text_ends_with_line_space(&out) => {
245
+ trim_last_text_end(&mut out);
246
+ out.push(Event::End(end));
247
+ changed = true;
248
+ }
249
+ Event::SoftBreak | Event::HardBreak if last_text_ends_with_line_space(&out) => {
250
+ trim_last_text_end(&mut out);
251
+ out.push(event);
252
+ changed = true;
253
+ }
254
+ Event::Text(text) if text.starts_with(is_line_space) && opens_line(out.last()) => {
255
+ let body = text.trim_start_matches(is_line_space);
256
+ if !body.is_empty() {
257
+ out.push(Event::Text(CowStr::from(body.to_owned())));
258
+ }
259
+ changed = true;
260
+ }
261
+ other => out.push(other),
262
+ }
263
+ }
264
+ *events = out;
265
+ changed
266
+ }
267
+
268
+ /// Keeps suppressed raw HTML inert in Markdown output.
269
+ ///
270
+ /// `suppress_raw_html` turns `Html` events into `Text` but leaves the
271
+ /// `HtmlBlock` container around them, which is what the HTML renderer
272
+ /// wants (escaped text, no `<p>`). The Markdown serializer, though, writes
273
+ /// an `HtmlBlock`'s contents verbatim, so that text would reparse as live
274
+ /// HTML. Blocks whose contents start with `Text` are rewritten as a
275
+ /// paragraph, one trimmed line per `SoftBreak`, so the serializer escapes
276
+ /// them. Blocks of real `Html` events (`raw_html: true`) pass through.
277
+ struct InertHtmlBlocks<'a, I: Iterator<Item = Event<'a>>> {
278
+ inner: std::iter::Peekable<I>,
279
+ pending: VecDeque<Event<'a>>,
280
+ }
281
+
282
+ impl<'a, I: Iterator<Item = Event<'a>>> InertHtmlBlocks<'a, I> {
283
+ fn new(inner: I) -> Self {
284
+ Self {
285
+ inner: inner.peekable(),
286
+ pending: VecDeque::new(),
287
+ }
288
+ }
289
+
290
+ /// Consume the rest of a suppressed HTML block (through its `End`) and
291
+ /// queue it as a paragraph.
292
+ fn queue_as_paragraph(&mut self) {
293
+ let mut lines: Vec<String> = Vec::new();
294
+ loop {
295
+ match self.inner.next() {
296
+ // An `Html` event in a block that was otherwise suppressed
297
+ // is escaped too: staying inert is the safe default.
298
+ Some(Event::Text(t)) | Some(Event::Html(t)) => lines.extend(
299
+ t.split('\n')
300
+ .map(str::trim)
301
+ .filter(|l| !l.is_empty())
302
+ .map(str::to_owned),
303
+ ),
304
+ Some(Event::End(TagEnd::HtmlBlock)) | None => break,
305
+ Some(_) => {}
306
+ }
307
+ }
308
+ if lines.is_empty() {
309
+ return;
310
+ }
311
+ self.pending.push_back(Event::Start(Tag::Paragraph));
312
+ for (i, line) in lines.into_iter().enumerate() {
313
+ if i > 0 {
314
+ self.pending.push_back(Event::SoftBreak);
315
+ }
316
+ self.pending.push_back(Event::Text(CowStr::from(line)));
317
+ }
318
+ self.pending.push_back(Event::End(TagEnd::Paragraph));
319
+ }
320
+ }
321
+
322
+ impl<'a, I: Iterator<Item = Event<'a>>> Iterator for InertHtmlBlocks<'a, I> {
323
+ type Item = Event<'a>;
324
+
325
+ fn next(&mut self) -> Option<Event<'a>> {
326
+ loop {
327
+ if let Some(event) = self.pending.pop_front() {
328
+ return Some(event);
329
+ }
330
+ match self.inner.next()? {
331
+ Event::Start(Tag::HtmlBlock)
332
+ if matches!(self.inner.peek(), Some(Event::Text(_))) =>
333
+ {
334
+ // May queue nothing (all-blank block); loop either way.
335
+ self.queue_as_paragraph();
336
+ }
337
+ event => return Some(event),
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ #[cfg(test)]
344
+ mod tests {
345
+ use super::*;
346
+ use pulldown_cmark::Parser;
347
+
348
+ fn suppressed(source: &str) -> Vec<Event<'_>> {
349
+ Parser::new_ext(source, Options::empty())
350
+ .map(|e| match e {
351
+ Event::Html(h) | Event::InlineHtml(h) => Event::Text(h),
352
+ other => other,
353
+ })
354
+ .collect()
355
+ }
356
+
357
+ fn reparse_has_html(markdown: &str) -> bool {
358
+ Parser::new_ext(markdown, Options::empty())
359
+ .any(|e| matches!(e, Event::Html(_) | Event::InlineHtml(_)))
360
+ }
361
+
362
+ #[test]
363
+ fn suppressed_html_block_stays_inert() {
364
+ let md = render(
365
+ &suppressed("<div onclick=\"x()\">\n*inside*\n</div>\n"),
366
+ Options::empty(),
367
+ )
368
+ .unwrap();
369
+ assert!(!reparse_has_html(&md), "reparsed as live HTML: {md:?}");
370
+ assert!(md.contains("div"), "lost the escaped text: {md:?}");
371
+ }
372
+
373
+ #[test]
374
+ fn suppressed_html_block_drops_blank_lines() {
375
+ // HTML block types 1-5 (here <script>) may contain blank lines,
376
+ // which would end a paragraph on reparse.
377
+ let md = render(
378
+ &suppressed("<script>\n\nalert(1)\n</script>\n"),
379
+ Options::empty(),
380
+ )
381
+ .unwrap();
382
+ assert!(!reparse_has_html(&md), "reparsed as live HTML: {md:?}");
383
+ assert!(md.contains("alert(1)"), "lost the escaped text: {md:?}");
384
+ }
385
+
386
+ #[test]
387
+ fn live_html_block_passes_through() {
388
+ let events: Vec<Event> =
389
+ Parser::new_ext("<div>\nraw\n</div>\n", Options::empty()).collect();
390
+ let md = render(&events, Options::empty()).unwrap();
391
+ assert!(md.contains("<div>"), "raw HTML was rewritten: {md:?}");
392
+ }
393
+
394
+ /// Parse `source` and drop every link's Start/End, as the allowlist
395
+ /// filters do when they unwrap a disallowed link to its text.
396
+ fn unwrapped_links(source: &str) -> Vec<Event<'_>> {
397
+ Parser::new_ext(source, Options::empty())
398
+ .filter(|e| !matches!(e, Event::Start(Tag::Link { .. }) | Event::End(TagEnd::Link)))
399
+ .collect()
400
+ }
401
+
402
+ #[test]
403
+ fn unwrapped_link_edges_inside_emphasis_serialize() {
404
+ for source in [
405
+ "**[text\n](u)**",
406
+ "**[\ntext](u)**",
407
+ "**[text ](u)**",
408
+ "**[ text](u)**",
409
+ "**_[text\n](u)_**",
410
+ "a **[text ](u)** b",
411
+ ] {
412
+ let md = render(&unwrapped_links(source), Options::empty())
413
+ .unwrap_or_else(|e| panic!("{source:?} failed: {e}"));
414
+ let strong = Parser::new_ext(&md, Options::empty())
415
+ .any(|e| matches!(e, Event::Start(Tag::Strong)));
416
+ assert!(strong && md.contains("text"), "{source:?} => {md:?}");
417
+ }
418
+ }
419
+
420
+ #[test]
421
+ fn emphasis_emptied_by_unwrapping_is_dropped() {
422
+ let md = render(&unwrapped_links("a *[](u)* b"), Options::empty()).unwrap();
423
+ assert!(!md.contains('*'), "{md:?}");
424
+ }
425
+
426
+ #[test]
427
+ fn unbalanced_events_return_an_error_instead_of_panicking() {
428
+ let events = vec![
429
+ Event::Start(Tag::Paragraph),
430
+ Event::Text(CowStr::Borrowed("x")),
431
+ Event::End(TagEnd::Paragraph),
432
+ Event::End(TagEnd::BlockQuote(None)),
433
+ ];
434
+ assert!(render(&events, Options::empty()).is_err());
435
+ }
436
+ }