git.lucas.co / cce-vault
notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git

src/markdown.rs (32.7K)

  1 //! A note as a reading view needs it: blocks of styled spans, with Obsidian's
  2 //! additions (wikilinks, embeds, `==highlights==`, `#tags`, callouts, custom
  3 //! task statuses, `%%comments%%` hidden) and the source line of every block
  4 //! and task so a click can act on the file.
  5 //!
  6 //! This is a document model, not a renderer: it knows nothing about fonts or
  7 //! pixels, so the notes editor, the desktop's note cards and the graph's hover
  8 //! previews can each lay it out their own way.
  9 //!
 10 //! Unlike [`crate::parse`], which scans raw bytes so a rename can rewrite
 11 //! links in place, this uses pulldown-cmark's own `ENABLE_WIKILINKS` — a
 12 //! renderer needs the link's display text and nesting, not its byte span.
 13 //! Obsidian treats a single newline as a line break (its default
 14 //! "strict line breaks" is off), and so does this.
 15 
 16 use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd};
 17 
 18 use crate::parse::{self, LineIndex};
 19 
 20 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
 21 pub struct Style {
 22     pub bold: bool,
 23     pub italic: bool,
 24     pub strike: bool,
 25     pub code: bool,
 26     pub highlight: bool,
 27     pub math: bool,
 28 }
 29 
 30 #[derive(Debug, Clone, PartialEq, Eq)]
 31 pub enum SpanLink {
 32     /// A note or file in the vault: `[[target#subpath|…]]` or a relative
 33     /// markdown link. Resolve with [`crate::Index::resolve_text`].
 34     Note { target: String, subpath: Option<String> },
 35     /// `![[target]]` / `![alt](target)` inline in a paragraph.
 36     Embed { target: String, subpath: Option<String> },
 37     Url(String),
 38     /// A `#tag`, without the `#`.
 39     Tag(String),
 40 }
 41 
 42 #[derive(Debug, Clone, PartialEq, Eq)]
 43 pub struct Span {
 44     pub text: String,
 45     pub style: Style,
 46     pub link: Option<SpanLink>,
 47 }
 48 
 49 #[derive(Debug, Clone, PartialEq)]
 50 pub struct ListItem {
 51     /// The status character of a task item (`' '`, `'x'`, `'/'`, …).
 52     pub task: Option<char>,
 53     /// 0-based source line of the item's marker; with `task`, what
 54     /// [`crate::Index::set_task`] takes.
 55     pub line: usize,
 56     pub blocks: Vec<Block>,
 57 }
 58 
 59 #[derive(Debug, Clone, PartialEq)]
 60 pub struct Callout {
 61     /// Lowercased type: `note`, `warning`, `tip`, …
 62     pub kind: String,
 63     pub title: Vec<Span>,
 64     /// `Some(true)` for `[!x]-` (collapsed), `Some(false)` for `[!x]+`.
 65     pub folded: Option<bool>,
 66 }
 67 
 68 /// An embed's requested display size, in px: a width, and a height only
 69 /// when both were given (`300x200`); otherwise the height follows the
 70 /// image's aspect.
 71 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 72 pub struct EmbedSize {
 73     pub width: u32,
 74     pub height: Option<u32>,
 75 }
 76 
 77 /// The size an embed's alias text asks for: the part after its last `|`
 78 /// (or all of it), read as `W` or `WxH`. Anything else asks for none.
 79 pub fn embed_size(alias: &str) -> Option<EmbedSize> {
 80     let spec = alias.rsplit('|').next().unwrap_or(alias).trim();
 81     let (w, h) = match spec.split_once('x') {
 82         Some((w, h)) => (w, Some(h)),
 83         None => (spec, None),
 84     };
 85     let width = w.trim().parse::<u32>().ok().filter(|w| *w > 0)?;
 86     let height = match h {
 87         Some(h) => Some(h.trim().parse::<u32>().ok().filter(|h| *h > 0)?),
 88         None => None,
 89     };
 90     Some(EmbedSize { width, height })
 91 }
 92 
 93 /// Whether a vault path names an image Obsidian shows inline when embedded.
 94 pub fn is_image(path: &str) -> bool {
 95     let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
 96     matches!(ext.as_deref(), Some("png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "svg" | "avif"))
 97 }
 98 
 99 #[derive(Debug, Clone, PartialEq)]
100 pub enum Block {
101     /// Frontmatter, as (key, value shown as text) pairs.
102     Properties(Vec<(String, String)>),
103     Heading { level: u8, spans: Vec<Span>, line: usize },
104     Paragraph { spans: Vec<Span>, line: usize },
105     /// A paragraph that is nothing but one `![[embed]]`. `size` is the
106     /// display size asked for after the `|` — `![[pic.png|300]]`,
107     /// `![[pic.png|300x200]]`, `![alt|300](pic.png)` — as Obsidian reads it.
108     Embed { target: String, subpath: Option<String>, size: Option<EmbedSize>, line: usize },
109     List { start: Option<u64>, items: Vec<ListItem>, line: usize },
110     Quote { callout: Option<Callout>, blocks: Vec<Block>, line: usize },
111     Code { lang: Option<String>, text: String, line: usize },
112     Table { header: Vec<Vec<Span>>, rows: Vec<Vec<Vec<Span>>>, line: usize },
113     Rule { line: usize },
114 }
115 
116 /// Blank out `range` of `text` without moving any line: every byte but
117 /// newlines becomes a space. Keeps pulldown's offsets on the file's lines.
118 fn blank(text: &mut String, range: std::ops::Range<usize>) {
119     let replaced: String =
120         text[range.clone()].chars().map(|c| if c == '\n' || c == '\r' { c } else { ' ' }).collect();
121     text.replace_range(range, &replaced);
122 }
123 
124 /// `%%comment%%` ranges outside fenced code, found on the raw text.
125 fn comments(src: &str) -> Vec<std::ops::Range<usize>> {
126     let mut code: Vec<std::ops::Range<usize>> = Vec::new();
127     for (e, r) in Parser::new_ext(src, Options::empty()).into_offset_iter() {
128         if matches!(e, Event::Start(Tag::CodeBlock(_)) | Event::Code(_)) {
129             code.push(r);
130         }
131     }
132     let in_code = |at: usize| code.iter().any(|r| r.contains(&at));
133     let mut out = Vec::new();
134     let mut pos = 0;
135     while let Some(i) = src[pos..].find("%%") {
136         let start = pos + i;
137         if in_code(start) {
138             pos = start + 2;
139             continue;
140         }
141         let end = src[start + 2..].find("%%").map(|j| start + 2 + j + 2).unwrap_or(src.len());
142         out.push(start..end);
143         pos = end;
144     }
145     out
146 }
147 
148 enum Frame {
149     Root(Vec<Block>),
150     Quote { blocks: Vec<Block>, line: usize },
151     List { start: Option<u64>, items: Vec<ListItem>, line: usize },
152     Item { task: Option<char>, blocks: Vec<Block>, line: usize },
153 }
154 
155 impl Frame {
156     fn blocks(&mut self) -> &mut Vec<Block> {
157         match self {
158             Frame::Root(b) | Frame::Quote { blocks: b, .. } | Frame::Item { blocks: b, .. } => b,
159             Frame::List { .. } => unreachable!("a list holds items, not blocks"),
160         }
161     }
162 }
163 
164 struct Builder<'a> {
165     src: &'a str,
166     lines: LineIndex,
167     frames: Vec<Frame>,
168     spans: Vec<Span>,
169     text: String,
170     style: Style,
171     links: Vec<SpanLink>,
172     /// Where the inline run being collected began, when inside a block.
173     inline_line: Option<usize>,
174     code: Option<(Option<String>, String, usize)>,
175     table: Option<TableBuild>,
176 }
177 
178 #[derive(Default)]
179 struct TableBuild {
180     header: Vec<Vec<Span>>,
181     rows: Vec<Vec<Vec<Span>>>,
182     /// Cells of the row being read.
183     row: Vec<Vec<Span>>,
184     line: usize,
185 }
186 
187 impl<'a> Builder<'a> {
188     fn top(&mut self) -> &mut Frame {
189         self.frames.last_mut().expect("root frame")
190     }
191 
192     /// Turn buffered text into spans: `#tags` and `==highlights==` are
193     /// split out here, since pulldown knows neither.
194     fn flush_text(&mut self) {
195         if self.text.is_empty() {
196             return;
197         }
198         let text = std::mem::take(&mut self.text);
199         let link = self.links.last().cloned();
200         let plain = link.is_none() && !self.style.code && !self.style.math;
201         let mut style = self.style;
202         let pieces: Vec<&str> = if plain && text.matches("==").count() >= 2 {
203             text.split("==").collect()
204         } else {
205             vec![text.as_str()]
206         };
207         let toggles = pieces.len() > 1;
208         let last = pieces.len() - 1;
209         for (i, piece) in pieces.into_iter().enumerate() {
210             if plain {
211                 push_with_tags(&mut self.spans, piece, style);
212             } else {
213                 push_span(&mut self.spans, piece, style, link.clone());
214             }
215             // An odd trailing `==` has no partner: put it back as text.
216             if toggles && i < last {
217                 if i == last - 1 && !style.highlight && last % 2 == 1 {
218                     push_span(&mut self.spans, "==", style, None);
219                 } else {
220                     style.highlight = !style.highlight;
221                 }
222             }
223         }
224     }
225 
226     fn take_spans(&mut self) -> Vec<Span> {
227         self.flush_text();
228         let mut spans = std::mem::take(&mut self.spans);
229         // Trailing line breaks carry nothing.
230         while spans.last().is_some_and(|s| s.text.trim_matches('\n').is_empty() && s.text.contains('\n')) {
231             spans.pop();
232         }
233         spans
234     }
235 
236     /// Tight list items put their text straight in the item; close it into
237     /// a paragraph before anything else lands there.
238     fn close_loose_inline(&mut self) {
239         if let Some(line) = self.inline_line.take() {
240             let spans = self.take_spans();
241             if !spans.is_empty() {
242                 for block in paragraph_blocks(spans, line) {
243                     self.top().blocks().push(block);
244                 }
245             }
246         }
247     }
248 
249     fn push_block(&mut self, block: Block) {
250         self.top().blocks().push(block);
251     }
252 
253     fn line(&self, at: usize) -> usize {
254         self.lines.line_of(at.min(self.src.len().saturating_sub(1)))
255     }
256 }
257 
258 fn push_span(spans: &mut Vec<Span>, text: &str, style: Style, link: Option<SpanLink>) {
259     if text.is_empty() {
260         return;
261     }
262     if let Some(last) = spans.last_mut() {
263         if last.style == style && last.link == link && !matches!(link, Some(SpanLink::Tag(_))) {
264             last.text.push_str(text);
265             return;
266         }
267     }
268     spans.push(Span { text: text.to_string(), style, link });
269 }
270 
271 /// Push plain text, turning `#tags` into tag spans by the index's rules.
272 fn push_with_tags(spans: &mut Vec<Span>, text: &str, style: Style) {
273     let mut start = 0;
274     let mut prev: Option<char> = spans.last().and_then(|s| s.text.chars().next_back());
275     let mut iter = text.char_indices().peekable();
276     while let Some((i, c)) = iter.next() {
277         let boundary = prev.is_none_or(char::is_whitespace);
278         prev = Some(c);
279         if c != '#' || !boundary {
280             continue;
281         }
282         let rest = &text[i + 1..];
283         let len: usize = rest
284             .chars()
285             .take_while(|&c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/')
286             .map(char::len_utf8)
287             .sum();
288         let name = rest[..len].trim_end_matches('/');
289         if name.is_empty() || name.chars().all(|c| c.is_ascii_digit() || c == '/') {
290             continue;
291         }
292         push_span(spans, &text[start..i], style, None);
293         push_span(spans, &text[i..i + 1 + name.len()], style, Some(SpanLink::Tag(name.to_string())));
294         start = i + 1 + name.len();
295         while iter.peek().is_some_and(|&(j, _)| j < start) {
296             prev = iter.next().map(|(_, c)| c);
297         }
298     }
299     push_span(spans, &text[start..], style, None);
300 }
301 
302 /// A paragraph's blocks: a line of it that is nothing but one embed is an
303 /// [`Block::Embed`] of its own (Obsidian shows a picture wherever it sits,
304 /// and a pasted image lands on its own line inside a paragraph), and the
305 /// lines between stay paragraphs. One line in, as before.
306 fn paragraph_blocks(spans: Vec<Span>, line: usize) -> Vec<Block> {
307     if spans.is_empty() {
308         return Vec::new();
309     }
310     // Split into source lines at the soft and hard breaks ('\n' in text).
311     let mut lines: Vec<Vec<Span>> = vec![Vec::new()];
312     for s in &spans {
313         for (i, part) in s.text.split('\n').enumerate() {
314             if i > 0 {
315                 lines.push(Vec::new());
316             }
317             if !part.is_empty() {
318                 lines.last_mut().expect("one line").push(Span { text: part.to_string(), ..s.clone() });
319             }
320         }
321     }
322     let embed_line = |l: &[Span]| {
323         let mut real = l.iter().filter(|s| s.link.is_some() || !s.text.trim().is_empty());
324         matches!((real.next(), real.next()), (Some(Span { link: Some(SpanLink::Embed { .. }), .. }), None))
325     };
326     if lines.len() < 2 || !lines.iter().any(|l| embed_line(l)) {
327         return vec![paragraph_or_embed(spans, line)];
328     }
329     let mut out = Vec::new();
330     let mut para: Vec<Span> = Vec::new();
331     let mut para_line = line;
332     let flush = |para: &mut Vec<Span>, at: usize, out: &mut Vec<Block>| {
333         if !para.is_empty() {
334             out.push(Block::Paragraph { spans: std::mem::take(para), line: at });
335         }
336     };
337     for (i, l) in lines.into_iter().enumerate() {
338         if embed_line(&l) {
339             flush(&mut para, para_line, &mut out);
340             let embed = l.into_iter().find(|s| s.link.is_some()).expect("an embed line has one");
341             out.push(paragraph_or_embed(vec![embed], line + i));
342         } else {
343             if para.is_empty() {
344                 para_line = line + i;
345             } else {
346                 para.push(Span { text: "\n".into(), style: Style::default(), link: None });
347             }
348             para.extend(l);
349         }
350     }
351     flush(&mut para, para_line, &mut out);
352     out
353 }
354 
355 fn paragraph_or_embed(spans: Vec<Span>, line: usize) -> Block {
356     if let [Span { text, link: Some(SpanLink::Embed { target, subpath }), .. }] = spans.as_slice() {
357         return Block::Embed { target: target.clone(), subpath: subpath.clone(), size: embed_size(text), line };
358     }
359     Block::Paragraph { spans, line }
360 }
361 
362 fn split_target(dest: &str) -> (String, Option<String>) {
363     match dest.split_once('#') {
364         Some((t, s)) => (t.trim().to_string(), Some(s.trim().to_string()).filter(|s| !s.is_empty())),
365         None => (dest.trim().to_string(), None),
366     }
367 }
368 
369 fn is_url(dest: &str) -> bool {
370     dest.contains("://") || dest.starts_with("mailto:")
371 }
372 
373 fn decode(s: &str) -> String {
374     percent_encoding::percent_decode_str(s).decode_utf8_lossy().into_owned()
375 }
376 
377 /// `[!type]+ Title` at the start of a quote's first paragraph.
378 fn take_callout(blocks: &mut Vec<Block>) -> Option<Callout> {
379     let Some(Block::Paragraph { spans, .. }) = blocks.first_mut() else { return None };
380     let first = spans.first()?;
381     let rest = first.text.strip_prefix("[!")?;
382     let close = rest.find(']')?;
383     let kind = rest[..close].trim().to_lowercase();
384     if kind.is_empty() || kind.contains(char::is_whitespace) {
385         return None;
386     }
387     let mut after = &rest[close + 1..];
388     let folded = match after.chars().next() {
389         Some('-') => Some(true),
390         Some('+') => Some(false),
391         _ => None,
392     };
393     if folded.is_some() {
394         after = &after[1..];
395     }
396     // The title runs to the first line break; what follows is the body.
397     let mut title = Vec::new();
398     let mut body = Vec::new();
399     let mut in_title = true;
400     let first_rest = after.trim_start().to_string();
401     let style = first.style;
402     let mut all: Vec<Span> = Vec::new();
403     push_span(&mut all, &first_rest, style, None);
404     all.extend(spans.drain(1..));
405     for span in all {
406         if in_title {
407             if let Some((t, b)) = span.text.split_once('\n') {
408                 push_span(&mut title, t, span.style, span.link.clone());
409                 in_title = false;
410                 push_span(&mut body, b, span.style, span.link.clone());
411                 continue;
412             }
413             title.push(span);
414         } else {
415             body.push(span);
416         }
417     }
418     if body.iter().all(|s| s.text.trim().is_empty()) {
419         blocks.remove(0);
420     } else if let Some(Block::Paragraph { spans, .. }) = blocks.first_mut() {
421         *spans = body;
422     }
423     Some(Callout { kind, title, folded })
424 }
425 
426 /// A task status written as `[/] text` at the start of an item, which
427 /// pulldown only recognises for `[ ]` and `[x]`.
428 fn take_custom_status(blocks: &mut [Block]) -> Option<char> {
429     let Some(Block::Paragraph { spans, .. }) = blocks.first_mut() else { return None };
430     let first = spans.first_mut()?;
431     let t = first.text.as_str();
432     let mut chars = t.chars();
433     if chars.next() != Some('[') {
434         return None;
435     }
436     let status = chars.next()?;
437     if chars.next() != Some(']') || !matches!(chars.next(), Some(' ') | None) {
438         return None;
439     }
440     let cut = 3 + status.len_utf8() - 1 + if t.len() > 2 + status.len_utf8() { 1 } else { 0 };
441     first.text = t[cut.min(t.len())..].to_string();
442     Some(status)
443 }
444 
445 /// Parse a note into blocks.
446 pub fn blocks(src: &str) -> Vec<Block> {
447     let mut text = src.to_string();
448     let mut out_props = None;
449     if let Some((whole, inner)) = parse::frontmatter_range(src) {
450         let props = parse::parse_properties(&src[inner]);
451         out_props = Some(
452             props
453                 .iter()
454                 .map(|(k, v)| {
455                     let shown = match v {
456                         serde_json::Value::String(s) => s.clone(),
457                         serde_json::Value::Array(a) => a
458                             .iter()
459                             .map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string()))
460                             .collect::<Vec<_>>()
461                             .join(", "),
462                         serde_json::Value::Null => String::new(),
463                         other => other.to_string(),
464                     };
465                     (k.clone(), shown)
466                 })
467                 .collect::<Vec<_>>(),
468         );
469         blank(&mut text, whole);
470     }
471     for r in comments(&text) {
472         blank(&mut text, r);
473     }
474 
475     let mut b = Builder {
476         src: &text,
477         lines: LineIndex::new(&text),
478         frames: vec![Frame::Root(Vec::new())],
479         spans: Vec::new(),
480         text: String::new(),
481         style: Style::default(),
482         links: Vec::new(),
483         inline_line: None,
484         code: None,
485         table: None,
486     };
487     if let Some(props) = out_props.filter(|p| !p.is_empty()) {
488         b.top().blocks().push(Block::Properties(props));
489     }
490 
491     let opts = Options::ENABLE_TABLES
492         | Options::ENABLE_FOOTNOTES
493         | Options::ENABLE_STRIKETHROUGH
494         | Options::ENABLE_TASKLISTS
495         | Options::ENABLE_MATH
496         | Options::ENABLE_WIKILINKS;
497     let src_copy = text.clone();
498     for (event, range) in Parser::new_ext(&src_copy, opts).into_offset_iter() {
499         if let Some((_, buf, _)) = b.code.as_mut() {
500             match event {
501                 Event::Text(t) => {
502                     buf.push_str(&t);
503                     continue;
504                 }
505                 Event::End(TagEnd::CodeBlock) => {
506                     let (lang, mut body, line) = b.code.take().unwrap();
507                     if body.ends_with('\n') {
508                         body.pop();
509                     }
510                     b.push_block(Block::Code { lang, text: body, line });
511                     continue;
512                 }
513                 _ => continue,
514             }
515         }
516         match event {
517             Event::Start(Tag::Paragraph) | Event::Start(Tag::Heading { .. }) => {
518                 b.close_loose_inline();
519                 b.inline_line = None;
520                 b.spans.clear();
521             }
522             Event::End(TagEnd::Paragraph) => {
523                 let spans = b.take_spans();
524                 let line = b.line(range.start);
525                 for block in paragraph_blocks(spans, line) {
526                     b.push_block(block);
527                 }
528             }
529             Event::End(TagEnd::Heading(level)) => {
530                 let spans = b.take_spans();
531                 let line = b.line(range.start);
532                 let level = match level {
533                     HeadingLevel::H1 => 1,
534                     HeadingLevel::H2 => 2,
535                     HeadingLevel::H3 => 3,
536                     HeadingLevel::H4 => 4,
537                     HeadingLevel::H5 => 5,
538                     HeadingLevel::H6 => 6,
539                 };
540                 b.push_block(Block::Heading { level, spans, line });
541             }
542             Event::Start(Tag::BlockQuote(_)) => {
543                 b.close_loose_inline();
544                 let line = b.line(range.start);
545                 b.frames.push(Frame::Quote { blocks: Vec::new(), line });
546             }
547             Event::End(TagEnd::BlockQuote(_)) => {
548                 b.close_loose_inline();
549                 if let Some(Frame::Quote { mut blocks, line }) = b.frames.pop() {
550                     let callout = take_callout(&mut blocks);
551                     b.push_block(Block::Quote { callout, blocks, line });
552                 }
553             }
554             Event::Start(Tag::List(start)) => {
555                 b.close_loose_inline();
556                 let line = b.line(range.start);
557                 b.frames.push(Frame::List { start, items: Vec::new(), line });
558             }
559             Event::End(TagEnd::List(_)) => {
560                 if let Some(Frame::List { start, items, line }) = b.frames.pop() {
561                     b.push_block(Block::List { start, items, line });
562                 }
563             }
564             Event::Start(Tag::Item) => {
565                 let line = b.line(range.start);
566                 b.frames.push(Frame::Item { task: None, blocks: Vec::new(), line });
567                 b.inline_line = Some(line);
568             }
569             Event::End(TagEnd::Item) => {
570                 b.close_loose_inline();
571                 if let Some(Frame::Item { mut task, mut blocks, line }) = b.frames.pop() {
572                     if task.is_none() {
573                         task = take_custom_status(&mut blocks);
574                     }
575                     if let Some(Frame::List { items, .. }) = b.frames.last_mut() {
576                         items.push(ListItem { task, line, blocks });
577                     }
578                 }
579             }
580             Event::TaskListMarker(checked) => {
581                 if let Some(Frame::Item { task, .. }) = b.frames.last_mut() {
582                     *task = Some(if checked { 'x' } else { ' ' });
583                 } else if let Some(Frame::Item { task, .. }) =
584                     b.frames.iter_mut().rev().find(|f| matches!(f, Frame::Item { .. }))
585                 {
586                     *task = Some(if checked { 'x' } else { ' ' });
587                 }
588             }
589             Event::Start(Tag::CodeBlock(kind)) => {
590                 b.close_loose_inline();
591                 let lang = match kind {
592                     CodeBlockKind::Fenced(l) if !l.trim().is_empty() => Some(l.trim().to_string()),
593                     _ => None,
594                 };
595                 let line = b.line(range.start);
596                 b.code = Some((lang, String::new(), line));
597             }
598             Event::DisplayMath(m) => {
599                 b.flush_text();
600                 let line = b.line(range.start);
601                 b.close_loose_inline();
602                 b.push_block(Block::Code { lang: Some("math".into()), text: m.to_string(), line });
603             }
604             Event::Rule => {
605                 b.close_loose_inline();
606                 let line = b.line(range.start);
607                 b.push_block(Block::Rule { line });
608             }
609             Event::Start(Tag::Table(_)) => {
610                 b.close_loose_inline();
611                 let line = b.line(range.start);
612                 b.table = Some(TableBuild { line, ..Default::default() });
613             }
614             Event::End(TagEnd::TableHead) => {
615                 if let Some(t) = b.table.as_mut() {
616                     t.header = std::mem::take(&mut t.row);
617                 }
618             }
619             Event::End(TagEnd::TableRow) => {
620                 if let Some(t) = b.table.as_mut() {
621                     let row = std::mem::take(&mut t.row);
622                     t.rows.push(row);
623                 }
624             }
625             Event::End(TagEnd::TableCell) => {
626                 let spans = b.take_spans();
627                 if let Some(t) = b.table.as_mut() {
628                     t.row.push(spans);
629                 }
630             }
631             Event::End(TagEnd::Table) => {
632                 if let Some(t) = b.table.take() {
633                     b.push_block(Block::Table { header: t.header, rows: t.rows, line: t.line });
634                 }
635             }
636             Event::Start(Tag::Emphasis) => {
637                 b.flush_text();
638                 b.style.italic = true;
639             }
640             Event::End(TagEnd::Emphasis) => {
641                 b.flush_text();
642                 b.style.italic = false;
643             }
644             Event::Start(Tag::Strong) => {
645                 b.flush_text();
646                 b.style.bold = true;
647             }
648             Event::End(TagEnd::Strong) => {
649                 b.flush_text();
650                 b.style.bold = false;
651             }
652             Event::Start(Tag::Strikethrough) => {
653                 b.flush_text();
654                 b.style.strike = true;
655             }
656             Event::End(TagEnd::Strikethrough) => {
657                 b.flush_text();
658                 b.style.strike = false;
659             }
660             Event::Start(Tag::Link { link_type, dest_url, .. }) => {
661                 b.flush_text();
662                 let link = if matches!(link_type, LinkType::WikiLink { .. }) {
663                     let (target, subpath) = split_target(&dest_url);
664                     SpanLink::Note { target, subpath }
665                 } else if is_url(&dest_url) || matches!(link_type, LinkType::Autolink | LinkType::Email) {
666                     let url = if link_type == LinkType::Email { format!("mailto:{dest_url}") } else { dest_url.to_string() };
667                     SpanLink::Url(url)
668                 } else {
669                     let (target, subpath) = split_target(&decode(&dest_url));
670                     SpanLink::Note { target, subpath }
671                 };
672                 b.links.push(link);
673             }
674             Event::End(TagEnd::Link) => {
675                 b.flush_text();
676                 b.links.pop();
677             }
678             Event::Start(Tag::Image { dest_url, .. }) => {
679                 b.flush_text();
680                 let link = if is_url(&dest_url) {
681                     SpanLink::Url(dest_url.to_string())
682                 } else {
683                     let (target, subpath) = split_target(&decode(&dest_url));
684                     SpanLink::Embed { target, subpath }
685                 };
686                 b.links.push(link);
687             }
688             Event::End(TagEnd::Image) => {
689                 // An embed with no alt text shows its target.
690                 if b.text.is_empty() {
691                     if let Some(SpanLink::Embed { target, .. } | SpanLink::Url(target)) = b.links.last() {
692                         b.text = target.clone();
693                     }
694                 }
695                 b.flush_text();
696                 b.links.pop();
697             }
698             Event::Text(t) => {
699                 if b.inline_line.is_none() && matches!(b.frames.last(), Some(Frame::Item { .. })) {
700                     b.inline_line = Some(b.line(range.start));
701                 }
702                 b.text.push_str(&t);
703             }
704             Event::Code(t) => {
705                 b.flush_text();
706                 let link = b.links.last().cloned();
707                 push_span(&mut b.spans, &t, Style { code: true, ..b.style }, link);
708             }
709             Event::InlineMath(t) => {
710                 b.flush_text();
711                 push_span(&mut b.spans, &t, Style { math: true, ..b.style }, None);
712             }
713             Event::SoftBreak | Event::HardBreak => {
714                 b.text.push('\n');
715             }
716             Event::Html(t) | Event::InlineHtml(t) => b.text.push_str(&t),
717             Event::FootnoteReference(label) => {
718                 b.flush_text();
719                 push_span(&mut b.spans, &format!("[{label}]"), Style { code: false, ..b.style }, None);
720             }
721             _ => {}
722         }
723     }
724     b.close_loose_inline();
725     match b.frames.into_iter().next() {
726         Some(Frame::Root(blocks)) => blocks,
727         _ => Vec::new(),
728     }
729 }
730 
731 /// The plain text of some spans, for tests, titles and search snippets.
732 pub fn plain(spans: &[Span]) -> String {
733     spans.iter().map(|s| s.text.as_str()).collect()
734 }
735 
736 #[cfg(test)]
737 mod tests {
738     use super::*;
739 
740     fn para(b: &Block) -> &[Span] {
741         match b {
742             Block::Paragraph { spans, .. } => spans,
743             other => panic!("not a paragraph: {other:?}"),
744         }
745     }
746 
747     #[test]
748     fn inline_styles_links_tags() {
749         let doc = blocks("Some **bold** and *it* ~~gone~~ `code` ==mark== #tag/x and [[Note#Sec|shown]] [web](https://a.b) [md](Other%20Note.md)\n");
750         let spans = para(&doc[0]);
751         let find = |t: &str| spans.iter().find(|s| s.text == t).unwrap_or_else(|| panic!("{t} in {spans:?}"));
752         assert!(find("bold").style.bold);
753         assert!(find("it").style.italic);
754         assert!(find("gone").style.strike);
755         assert!(find("code").style.code);
756         assert!(find("mark").style.highlight);
757         assert_eq!(find("#tag/x").link, Some(SpanLink::Tag("tag/x".into())));
758         assert_eq!(find("shown").link, Some(SpanLink::Note { target: "Note".into(), subpath: Some("Sec".into()) }));
759         assert_eq!(find("web").link, Some(SpanLink::Url("https://a.b".into())));
760         assert_eq!(find("md").link, Some(SpanLink::Note { target: "Other Note.md".into(), subpath: None }));
761     }
762 
763     #[test]
764     fn single_newline_is_a_break_and_odd_highlight_stays() {
765         let doc = blocks("one\ntwo == three\n");
766         assert_eq!(plain(para(&doc[0])), "one\ntwo == three");
767         assert!(para(&doc[0]).iter().all(|s| !s.style.highlight));
768     }
769 
770     #[test]
771     fn frontmatter_comments_and_lines() {
772         let doc = blocks("---\ntitle: T\ntags: [a, b]\n---\n# Head\n%% hidden\nstill hidden %%\nBody [[Link]]\n");
773         assert_eq!(doc[0], Block::Properties(vec![("title".into(), "T".into()), ("tags".into(), "a, b".into())]));
774         match &doc[1] {
775             Block::Heading { level: 1, spans, line: 4 } => assert_eq!(plain(spans), "Head"),
776             other => panic!("{other:?}"),
777         }
778         match &doc[2] {
779             Block::Paragraph { spans, line } => {
780                 assert_eq!(plain(spans).trim(), "Body Link");
781                 assert_eq!(*line, 7);
782             }
783             other => panic!("{other:?}"),
784         }
785     }
786 
787     #[test]
788     fn lists_tasks_and_custom_statuses() {
789         let doc = blocks("- [ ] open\n- [x] done\n- [/] half\n- plain\n  - nested\n1. first\n");
790         let Block::List { start: None, items, .. } = &doc[0] else { panic!("{doc:?}") };
791         let got: Vec<_> = items.iter().map(|i| (i.task, i.line, plain(para(&i.blocks[0])))).collect();
792         assert_eq!(
793             got,
794             [
795                 (Some(' '), 0, "open".to_string()),
796                 (Some('x'), 1, "done".to_string()),
797                 (Some('/'), 2, "half".to_string()),
798                 (None, 3, "plain".to_string()),
799             ]
800         );
801         let Block::List { items: nested, .. } = &items[3].blocks[1] else { panic!("{:?}", items[3]) };
802         assert_eq!(plain(para(&nested[0].blocks[0])), "nested");
803         assert!(matches!(doc[1], Block::List { start: Some(1), .. }));
804     }
805 
806     #[test]
807     fn callouts_and_quotes() {
808         let doc = blocks("> [!Warning]- Careful **now**\n> body text\n\n> plain quote\n");
809         let Block::Quote { callout: Some(c), blocks, .. } = &doc[0] else { panic!("{doc:?}") };
810         assert_eq!(c.kind, "warning");
811         assert_eq!(c.folded, Some(true));
812         assert_eq!(plain(&c.title), "Careful now");
813         assert_eq!(plain(para(&blocks[0])), "body text");
814         assert!(matches!(&doc[1], Block::Quote { callout: None, .. }));
815     }
816 
817     #[test]
818     fn code_tables_embeds_rules() {
819         let doc = blocks("```rust\nfn x() {}\n```\n\n| a | b |\n|---|---|\n| [[L]] | 2 |\n\n![[Pic.png]]\n\n---\n");
820         assert_eq!(doc[0], Block::Code { lang: Some("rust".into()), text: "fn x() {}".into(), line: 0 });
821         let Block::Table { header, rows, .. } = &doc[1] else { panic!("{doc:?}") };
822         assert_eq!(header.iter().map(|c| plain(c)).collect::<Vec<_>>(), ["a", "b"]);
823         assert_eq!(rows[0][0][0].link, Some(SpanLink::Note { target: "L".into(), subpath: None }));
824         assert_eq!(doc[2], Block::Embed { target: "Pic.png".into(), subpath: None, size: None, line: 8 });
825         assert!(matches!(doc[3], Block::Rule { line: 10 }));
826     }
827 
828     #[test]
829     fn an_embed_on_its_own_line_inside_a_paragraph_is_a_block() {
830         let doc = blocks("Some text.\n![[pic.png|200]]\nmore *text*\nand more\n");
831         assert_eq!(doc.len(), 3, "{doc:?}");
832         assert!(matches!(&doc[0], Block::Paragraph { line: 0, .. }));
833         assert_eq!(doc[1], Block::Embed { target: "pic.png".into(), subpath: None, size: Some(EmbedSize { width: 200, height: None }), line: 1 });
834         let Block::Paragraph { spans, line: 2 } = &doc[2] else { panic!("{doc:?}") };
835         assert_eq!(plain(spans), "more text\nand more");
836         // Mid-sentence it stays inline; a plain paragraph is untouched.
837         assert!(matches!(&blocks("see ![[pic.png]] here\n")[0], Block::Paragraph { .. }));
838         assert_eq!(blocks("a\nb\n").len(), 1);
839     }
840 
841     #[test]
842     fn embed_sizes() {
843         let doc = blocks("![[a.png|300]]\n\n![[b.png|300x200]]\n\n![alt|120](c.png)\n\n![[d.png|caption]]\n");
844         let sizes: Vec<_> = doc.iter().map(|b| match b {
845             Block::Embed { size, .. } => *size,
846             other => panic!("{other:?}"),
847         }).collect();
848         assert_eq!(sizes, [
849             Some(EmbedSize { width: 300, height: None }),
850             Some(EmbedSize { width: 300, height: Some(200) }),
851             Some(EmbedSize { width: 120, height: None }),
852             None,
853         ]);
854         assert!(is_image("Pics/A.PNG") && !is_image("Note.md") && !is_image("README"));
855     }
856 }