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.
@@ -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
- }
@@ -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