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]]` / `` 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]]`, `` — 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\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 }