inkmark 0.2.1 → 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 +18 -0
- data/Cargo.lock +61 -60
- 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 +4 -3
|
@@ -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, ¶ms),
|
|
53
|
-
BoundaryAt::Word => chunk_words(&events, ¶ms),
|
|
54
|
-
}
|
|
54
|
+
BoundaryAt::Block => chunk_blocks(&events, ¶ms, md_opts),
|
|
55
|
+
BoundaryAt::Word => chunk_words(&events, ¶ms, 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(
|
|
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 =
|
|
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(
|
|
185
|
-
|
|
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
|
data/ext/inkmark/src/document.rs
CHANGED
|
@@ -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 =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
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
|
|