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 +4 -4
- data/CHANGELOG.md +14 -0
- data/Cargo.lock +4 -3
- data/README.md +20 -0
- data/ext/inkmark/Cargo.toml +2 -2
- data/ext/inkmark/src/chunks_by_heading.rs +91 -31
- data/ext/inkmark/src/chunks_by_size.rs +33 -18
- data/ext/inkmark/src/document.rs +22 -20
- data/ext/inkmark/src/image.rs +57 -30
- data/ext/inkmark/src/lib.rs +1 -0
- data/ext/inkmark/src/link.rs +10 -9
- data/ext/inkmark/src/markdown.rs +436 -0
- data/ext/inkmark/src/scheme_filter.rs +31 -6
- data/ext/inkmark/src/truncate.rs +40 -23
- data/lib/inkmark/version.rb +1 -1
- metadata +2 -1
data/ext/inkmark/src/image.rs
CHANGED
|
@@ -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::
|
|
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::
|
|
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
|
-
|
|
42
|
-
// alt text from Text and Code payloads. Images can contain
|
|
43
|
-
// inline formatting (e.g. ``), 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::
|
|
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
|
|
72
|
-
///
|
|
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
|
|
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 (``) discarded. An image nested in
|
|
82
|
+
/// the alt text (`](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::
|
|
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 = "](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![
|
data/ext/inkmark/src/lib.rs
CHANGED
data/ext/inkmark/src/link.rs
CHANGED
|
@@ -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::
|
|
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::
|
|
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::
|
|
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:
|
|
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::
|
|
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
|
|
226
|
+
other => panic!("expected InlineHtml open event, got {other:?}"),
|
|
226
227
|
}
|
|
227
228
|
match &out[2] {
|
|
228
|
-
Event::
|
|
229
|
-
other => panic!("expected
|
|
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
|
+
}
|