magical markdown slides

feat add admonition support for GitHub/Obsidian and fence syntax

+2 -1
.markdownlint.json
··· 1 1 { 2 2 "MD033": false, 3 - "MD013": false 3 + "MD013": false, 4 + "MD028": false 4 5 }
+3
.vscode/settings.json
··· 1 + { 2 + "rust-analyzer.check.command": "clippy" 3 + }
+2
Cargo.lock
··· 478 478 "thiserror", 479 479 "toml", 480 480 "tracing", 481 + "unicode-width 0.2.0", 481 482 ] 482 483 483 484 [[package]] ··· 488 489 "lantern-core", 489 490 "owo-colors", 490 491 "ratatui", 492 + "unicode-width 0.2.0", 491 493 ] 492 494 493 495 [[package]]
+3 -4
README.md
··· 65 65 66 66 ### Navigation 67 67 68 - | Key | Action | 69 - | ------------ | ------------------- | 68 + | Key | Action | 69 + | ------------- | ------------------- | 70 70 | `→`, `j`, `n` | Next slide | 71 71 | `←`, `k`, `p` | Previous slide | 72 - | `1-9` | Jump to slide | 73 - | `q` | Quit | 72 + | `q` | Quit | 74 73 75 74 ## Design Principles 76 75
+8 -35
ROADMAP.md
··· 1 - # Slides 1 + # lantern 2 2 3 3 ## Plumbing 4 4 5 - __Objective:__ Establish a clean, testable core with `clap` and a minimal `ratatui` loop. 5 + __Outcome:__ initialize workspace with clap CLI and ratatui terminal setup 6 6 7 - | Task | Description | Key Crates | 8 - | ---------------------------- | ------------------------------------------------------------------------------ | --------------------------- | 9 - | __✓ Project Scaffolding__ | Initialize workspace with `slides-core`, `slides-cli`, and `slides-ui` crates. | `cargo`, `just`, `clap` | 10 - | | Use `cargo-generate` and a `justfile` for scripts. | | 11 - | __✓ CLI Definition__ | Implement root command `slides` with subcommands: | `clap`[^1] | 12 - | | • `present` (TUI) | | 13 - | | • `print` (stdout) | | 14 - | | • `init` (scaffold deck) | | 15 - | | • `check` (lint slides). | | 16 - | __✓ Logging & Colors__ | Integrate structured logs via `tracing`. | `owo-colors`[^2], `tracing` | 17 - | | Use __owo-colors__ for color abstraction (no dynamic dispatch). | | 18 - | __✓ Terminal & Event Setup__ | Configure alternate screen, raw mode, input loop, resize handler. | `crossterm`[^3], `ratatui` | 19 - | __CI/CD + Tooling__ | Setup `cargo fmt`, `clippy`, `test`, and `cross` matrix CI. | GitHub Actions | 7 + Scaffolded multi-crate workspace with present, print, init, and check subcommands, integrated structured logging via tracing, and configured alternate screen with crossterm input handling. 20 8 21 - ## Data Model (Parser & Slides) 9 + ## Data Model 22 10 23 - __Objective:__ Parse markdown documents into a rich `Slide` struct. 24 - 25 - | Task | Description | Key Crates | 26 - | ------------------------ | --------------------------------------------------------------- | -------------------- | 27 - | __✓ Parser Core__ | Split files on `---` separators. | `pulldown-cmark`[^4] | 28 - | | Detect title blocks, lists, and code fences. | | 29 - | | Represent as `Vec<Slide>`. | | 30 - | __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | 31 - | __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | `serde_yml`[^5] | 32 - | __✓ Error & Validation__ | Provide friendly parser errors with file/line info. | `thiserror`[^6] | 33 - | __✓ Basic CLI UX__ | `lantern present file.md` runs full TUI. | `clap` | 34 - | | `lantern print` renders to stdout with width constraint. | | 11 + __Outcome:__ implement markdown parser with metadata and validation 12 + Built pulldown-cmark-based parser that splits on --- separators into Vec<Slide>, supports YAML/TOML front matter, and provides friendly error messages with file/line context. 35 13 36 14 ## Rendering & Navigation 37 15 ··· 92 70 93 71 | Task | Description | Key Crates | 94 72 | -------------------- | ------------------------------------------------------------------ | ---------------------------- | 95 - | __Config Discovery__ | Read from `$XDG_CONFIG_HOME/slides/config.toml` for defaults. | `dirs`, `serde` | 73 + | __CI/CD + Tooling__ | Setup `cargo fmt`, `clippy`, `test`, and `cross` matrix CI | GitHub Actions | 74 + | __Config Discovery__ | Read from `$XDG_CONFIG_HOME/lantern/config.toml` for defaults | `dirs`, `serde` | 96 75 | __Theme Registry__ | Built-in theme manifest (e.g., `onedark`, `solarized`, `plain`). | Internal | 97 76 | __Release__ | Tag `v1.0.0-rc.1` with changelog and binaries for major platforms. | `cargo-dist`, GitHub Actions | 98 77 ··· 160 139 | __Deterministic Seeds__ | Add `--seed` for any animations (typing jitter, cursor blink timing) to keep exports repeatable. | internal `timeline` | 161 140 | __Preset Profiles__ | Presets like `social-card`, `doc-screenshot`, `talk-demo` mapping to resolution + theme. | internal profile registry | 162 141 163 - [^1]: <https://docs.rs/clap/latest/clap/> 164 - [^2]: <https://docs.rs/owo-colors/latest/owo_colors/> 165 - [^3]: <https://docs.rs/crossterm/latest/crossterm/> 166 - [^4]: <https://docs.rs/pulldown-cmark/latest/pulldown_cmark/> 167 - [^5]: <https://docs.rs/serde_yml> 168 - [^6]: <https://docs.rs/thiserror> 169 142 [^7]: <https://docs.rs/ratatui/latest/ratatui/> 170 143 [^8]: <https://docs.rs/syntect/latest/syntect/> 171 144 [^9]: <https://docs.rs/notify/latest/notify/>
+3 -2
core/Cargo.toml
··· 13 13 serde_yml = "0.0.12" 14 14 syntect = "5" 15 15 terminal-colorsaurus = "1.0.1" 16 - thiserror = "2.0.17" 17 - toml = "0.9.7" 16 + thiserror = "2" 17 + toml = "0.9" 18 + unicode-width = "0.2"
+12
core/src/highlighter.rs
··· 204 204 ui_title: Color::new(200, 200, 200), 205 205 ui_text: Color::new(220, 220, 220), 206 206 ui_background: Color::new(30, 30, 30), 207 + admonition_note: Color::new(100, 150, 200), 208 + admonition_tip: Color::new(150, 100, 200), 209 + admonition_warning: Color::new(200, 150, 50), 210 + admonition_danger: Color::new(200, 50, 50), 211 + admonition_success: Color::new(50, 200, 100), 212 + admonition_info: Color::new(100, 200, 200), 207 213 }; 208 214 209 215 assert!(is_dark_theme(&dark_theme)); ··· 231 237 ui_title: Color::new(40, 40, 40), 232 238 ui_text: Color::new(20, 20, 20), 233 239 ui_background: Color::new(250, 250, 250), 240 + admonition_note: Color::new(0, 100, 200), 241 + admonition_tip: Color::new(100, 0, 200), 242 + admonition_warning: Color::new(200, 100, 0), 243 + admonition_danger: Color::new(200, 0, 0), 244 + admonition_success: Color::new(0, 150, 50), 245 + admonition_info: Color::new(0, 150, 200), 234 246 }; 235 247 236 248 assert!(!is_dark_theme(&light_theme));
+352 -87
core/src/parser.rs
··· 18 18 sections.into_iter().map(parse_slide).collect() 19 19 } 20 20 21 + /// Preprocess markdown to convert admonition syntax to a format we can parse 22 + /// 23 + /// Converts both GitHub/Obsidian syntax (`> [!NOTE]`) and fence syntax (`:::note`) 24 + /// into a special HTML-like format that we can detect in the event stream 25 + fn preprocess_admonitions(markdown: &str) -> String { 26 + let mut result = String::new(); 27 + let lines: Vec<&str> = markdown.lines().collect(); 28 + let mut i = 0; 29 + 30 + while i < lines.len() { 31 + let line = lines[i]; 32 + let trimmed = line.trim(); 33 + 34 + if let Some(admonition_type) = parse_fence_admonition(trimmed) { 35 + result.push_str(&format!("<admonition type=\"{admonition_type}\">\n")); 36 + i += 1; 37 + while i < lines.len() { 38 + let content_line = lines[i]; 39 + if content_line.trim() == ":::" { 40 + result.push_str("</admonition>\n"); 41 + i += 1; 42 + break; 43 + } 44 + result.push_str(content_line); 45 + result.push('\n'); 46 + i += 1; 47 + } 48 + continue; 49 + } 50 + 51 + if trimmed.starts_with('>') { 52 + if let Some((admonition_type, title)) = parse_blockquote_admonition(trimmed) { 53 + result.push_str(&format!("<admonition type=\"{admonition_type}\"")); 54 + if let Some(t) = title { 55 + result.push_str(&format!(" title=\"{t}\"")); 56 + } 57 + result.push_str(">\n"); 58 + i += 1; 59 + 60 + while i < lines.len() { 61 + let next_line = lines[i]; 62 + let next_trimmed = next_line.trim(); 63 + if next_trimmed.starts_with('>') { 64 + let content = next_trimmed.strip_prefix('>').unwrap_or("").trim(); 65 + if !content.is_empty() { 66 + result.push_str(content); 67 + result.push('\n'); 68 + } 69 + i += 1; 70 + } else { 71 + break; 72 + } 73 + } 74 + result.push_str("</admonition>\n"); 75 + continue; 76 + } 77 + } 78 + 79 + result.push_str(line); 80 + result.push('\n'); 81 + i += 1; 82 + } 83 + 84 + result 85 + } 86 + 87 + /// Parse fence-style admonition: `:::note` or `:::warning Title` 88 + fn parse_fence_admonition(line: &str) -> Option<String> { 89 + let trimmed = line.trim(); 90 + if !trimmed.starts_with(":::") { 91 + return None; 92 + } 93 + 94 + let content = trimmed.strip_prefix(":::").unwrap_or("").trim(); 95 + if content.is_empty() { 96 + return None; 97 + } 98 + 99 + let parts: Vec<&str> = content.splitn(2, ' ').collect(); 100 + let admonition_type = parts[0].to_lowercase(); 101 + 102 + if admonition_type.is_empty() { None } else { Some(admonition_type) } 103 + } 104 + 105 + /// Parse blockquote-style admonition: `> [!NOTE]` or `> [!TIP] Custom Title` 106 + fn parse_blockquote_admonition(line: &str) -> Option<(String, Option<String>)> { 107 + let content = line.trim().strip_prefix('>')?.trim(); 108 + 109 + if !content.starts_with("[!") { 110 + return None; 111 + } 112 + 113 + let rest = content.strip_prefix("[!")?; 114 + let close_bracket = rest.find(']')?; 115 + let admonition_type = rest[..close_bracket].to_lowercase(); 116 + 117 + let title = rest[close_bracket + 1..].trim(); 118 + let title = if title.is_empty() { None } else { Some(title.to_string()) }; 119 + 120 + Some((admonition_type, title)) 121 + } 122 + 123 + /// Parse HTML admonition tag: `<admonition type="note" title="Title">` 124 + fn parse_admonition_html_start(html: &str) -> Option<(AdmonitionType, Option<String>)> { 125 + let html = html.trim(); 126 + if !html.starts_with("<admonition") { 127 + return None; 128 + } 129 + 130 + let type_start = html.find("type=\"")?; 131 + let type_value_start = type_start + 6; 132 + let type_end = html[type_value_start..].find('"')? + type_value_start; 133 + let admonition_type_str = &html[type_value_start..type_end]; 134 + let admonition_type = admonition_type_str.parse().ok()?; 135 + 136 + let title = if let Some(title_start) = html.find("title=\"") { 137 + let title_value_start = title_start + 7; 138 + let title_end = html[title_value_start..].find('"')? + title_value_start; 139 + Some(html[title_value_start..title_end].to_string()) 140 + } else { 141 + None 142 + }; 143 + 144 + Some((admonition_type, title)) 145 + } 146 + 21 147 /// Split markdown content on `---` separators 148 + /// 149 + /// Ignores `---` inside fenced code blocks to avoid incorrect slide splits 22 150 fn split_slides(markdown: &str) -> Vec<String> { 23 151 let mut slides = Vec::new(); 24 152 let mut current = String::new(); 153 + let mut in_code_block = false; 25 154 26 155 for line in markdown.lines() { 27 156 let trimmed = line.trim(); 28 - if trimmed == "---" { 157 + 158 + if trimmed.starts_with("```") || trimmed.starts_with("~~~") { 159 + in_code_block = !in_code_block; 160 + } 161 + 162 + if trimmed == "---" && !in_code_block { 29 163 if !current.trim().is_empty() { 30 164 slides.push(current); 31 165 current = String::new(); ··· 45 179 46 180 /// Parse a single slide from markdown 47 181 fn parse_slide(markdown: String) -> Result<Slide> { 182 + let preprocessed = preprocess_admonitions(&markdown); 48 183 let mut options = Options::empty(); 49 184 options.insert(Options::ENABLE_TABLES); 50 185 options.insert(Options::ENABLE_STRIKETHROUGH); 51 - let parser = Parser::new_ext(&markdown, options); 186 + let parser = Parser::new_ext(&preprocessed, options); 52 187 let mut blocks = Vec::new(); 53 188 let mut block_stack: Vec<BlockBuilder> = Vec::new(); 54 189 let mut current_style = TextStyle::default(); ··· 57 192 match event { 58 193 Event::Start(tag) => match tag { 59 194 Tag::Heading { level, .. } => { 60 - block_stack.push(BlockBuilder::Heading { 61 - level: level as u8, 62 - spans: Vec::new(), 63 - }); 195 + block_stack.push(BlockBuilder::Heading { level: level as u8, spans: Vec::new() }); 64 196 } 65 197 Tag::Paragraph => { 66 - block_stack.push(BlockBuilder::Paragraph { 67 - spans: Vec::new(), 68 - }); 198 + block_stack.push(BlockBuilder::Paragraph { spans: Vec::new() }); 69 199 } 70 200 Tag::CodeBlock(kind) => { 71 201 let language = match kind { ··· 78 208 } 79 209 pulldown_cmark::CodeBlockKind::Indented => None, 80 210 }; 81 - block_stack.push(BlockBuilder::Code { 82 - language, 83 - code: String::new(), 84 - }); 211 + block_stack.push(BlockBuilder::Code { language, code: String::new() }); 85 212 } 86 213 Tag::List(first) => { 87 214 block_stack.push(BlockBuilder::List { ··· 134 261 Event::End(tag_end) => match tag_end { 135 262 TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => { 136 263 if let Some(builder) = block_stack.pop() { 137 - blocks.push(builder.build()); 264 + let block = builder.build(); 265 + if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 266 + adm_blocks.push(block); 267 + } else { 268 + blocks.push(block); 269 + } 138 270 } 139 271 } 140 272 TagEnd::List(_) => { 141 273 if let Some(builder) = block_stack.pop() { 142 - blocks.push(builder.build()); 274 + let block = builder.build(); 275 + if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 276 + adm_blocks.push(block); 277 + } else { 278 + blocks.push(block); 279 + } 143 280 } 144 281 } 145 282 TagEnd::BlockQuote(_) => { 146 283 if let Some(builder) = block_stack.pop() { 147 - blocks.push(builder.build()); 284 + let block = builder.build(); 285 + if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 286 + adm_blocks.push(block); 287 + } else { 288 + blocks.push(block); 289 + } 148 290 } 149 291 } 150 292 TagEnd::Table => { 151 293 if let Some(builder) = block_stack.pop() { 152 - blocks.push(builder.build()); 294 + let block = builder.build(); 295 + if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 296 + adm_blocks.push(block); 297 + } else { 298 + blocks.push(block); 299 + } 153 300 } 154 301 } 155 302 TagEnd::TableHead => { 156 - if let Some(BlockBuilder::Table { 157 - current_row, 158 - headers, 159 - in_header, 160 - .. 161 - }) = block_stack.last_mut() 162 - { 303 + if let Some(BlockBuilder::Table { current_row, headers, in_header, .. }) = block_stack.last_mut() { 163 304 if !current_row.is_empty() { 164 305 *headers = std::mem::take(current_row); 165 306 } ··· 167 308 } 168 309 } 169 310 TagEnd::TableRow => { 170 - if let Some(BlockBuilder::Table { 171 - current_row, 172 - rows, 173 - .. 174 - }) = block_stack.last_mut() 175 - { 311 + if let Some(BlockBuilder::Table { current_row, rows, .. }) = block_stack.last_mut() { 176 312 if !current_row.is_empty() { 177 313 rows.push(std::mem::take(current_row)); 178 314 } 179 315 } 180 316 } 181 317 TagEnd::TableCell => { 182 - if let Some(BlockBuilder::Table { 183 - current_cell, 184 - current_row, 185 - .. 186 - }) = block_stack.last_mut() 187 - { 318 + if let Some(BlockBuilder::Table { current_cell, current_row, .. }) = block_stack.last_mut() { 188 319 current_row.push(std::mem::take(current_cell)); 189 320 } 190 321 } 191 322 TagEnd::Item => { 192 - if let Some(BlockBuilder::List { 193 - current_item, items, .. 194 - }) = block_stack.last_mut() 195 - { 323 + if let Some(BlockBuilder::List { current_item, items, .. }) = block_stack.last_mut() { 196 324 if !current_item.is_empty() { 197 - items.push(ListItem { 198 - spans: std::mem::take(current_item), 199 - nested: None, 200 - }); 325 + items.push(ListItem { spans: std::mem::take(current_item), nested: None }); 201 326 } 202 327 } 203 328 } ··· 235 360 blocks.push(Block::Rule); 236 361 } 237 362 363 + Event::Html(html) => { 364 + if let Some((admonition_type, title)) = parse_admonition_html_start(&html) { 365 + block_stack.push(BlockBuilder::Admonition { admonition_type, title, blocks: Vec::new() }); 366 + } else if html.trim().starts_with("</admonition>") { 367 + if let Some(builder) = block_stack.pop() { 368 + blocks.push(builder.build()); 369 + } 370 + } else if !block_stack.is_empty() { 371 + if let Some(BlockBuilder::Admonition { blocks: adm_blocks, .. }) = block_stack.last_mut() { 372 + let inner_markdown = html.to_string(); 373 + let inner_options = Options::empty(); 374 + let inner_parser = Parser::new_ext(&inner_markdown, inner_options); 375 + let mut inner_block_stack: Vec<BlockBuilder> = Vec::new(); 376 + let inner_style = TextStyle::default(); 377 + 378 + for inner_event in inner_parser { 379 + match inner_event { 380 + Event::Start(Tag::Paragraph) => { 381 + inner_block_stack.push(BlockBuilder::Paragraph { spans: Vec::new() }); 382 + } 383 + Event::Text(text) => { 384 + if let Some(builder) = inner_block_stack.last_mut() { 385 + builder.add_text(text.to_string(), &inner_style); 386 + } 387 + } 388 + Event::End(TagEnd::Paragraph) => { 389 + if let Some(builder) = inner_block_stack.pop() { 390 + adm_blocks.push(builder.build()); 391 + } 392 + } 393 + _ => {} 394 + } 395 + } 396 + } 397 + } 398 + } 399 + 238 400 _ => {} 239 401 } 240 402 } ··· 271 433 alignments: Vec<Alignment>, 272 434 in_header: bool, 273 435 }, 436 + Admonition { 437 + admonition_type: AdmonitionType, 438 + title: Option<String>, 439 + blocks: Vec<Block>, 440 + }, 274 441 } 275 442 276 443 impl BlockBuilder { ··· 278 445 match self { 279 446 Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 280 447 if !text.is_empty() { 281 - spans.push(TextSpan { 282 - text, 283 - style: current_style.clone(), 284 - }); 448 + spans.push(TextSpan { text, style: current_style.clone() }); 285 449 } 286 450 } 287 451 Self::Code { code, .. } => { ··· 289 453 } 290 454 Self::List { current_item, .. } => { 291 455 if !text.is_empty() { 292 - current_item.push(TextSpan { 293 - text, 294 - style: current_style.clone(), 295 - }); 456 + current_item.push(TextSpan { text, style: current_style.clone() }); 296 457 } 297 458 } 298 459 Self::Table { current_cell, .. } => { 299 460 if !text.is_empty() { 300 - current_cell.push(TextSpan { 301 - text, 302 - style: current_style.clone(), 303 - }); 461 + current_cell.push(TextSpan { text, style: current_style.clone() }); 304 462 } 305 463 } 464 + Self::Admonition { .. } => {} 306 465 _ => {} 307 466 } 308 467 } ··· 310 469 fn add_code_span(&mut self, code: String) { 311 470 match self { 312 471 Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 313 - spans.push(TextSpan { 314 - text: code, 315 - style: TextStyle { 316 - code: true, 317 - ..Default::default() 318 - }, 319 - }); 472 + spans.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } }); 320 473 } 321 474 Self::List { current_item, .. } => { 322 - current_item.push(TextSpan { 323 - text: code, 324 - style: TextStyle { 325 - code: true, 326 - ..Default::default() 327 - }, 328 - }); 475 + current_item.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } }); 329 476 } 330 477 Self::Table { current_cell, .. } => { 331 - current_cell.push(TextSpan { 332 - text: code, 333 - style: TextStyle { 334 - code: true, 335 - ..Default::default() 336 - }, 337 - }); 478 + current_cell.push(TextSpan { text: code, style: TextStyle { code: true, ..Default::default() } }); 338 479 } 480 + Self::Admonition { .. } => {} 339 481 _ => {} 340 482 } 341 483 } ··· 347 489 Self::Code { language, code } => Block::Code(CodeBlock { language, code }), 348 490 Self::List { ordered, items, .. } => Block::List(List { ordered, items }), 349 491 Self::BlockQuote { blocks } => Block::BlockQuote { blocks }, 350 - Self::Table { 351 - headers, 352 - rows, 353 - alignments, 354 - .. 355 - } => Block::Table(Table { 356 - headers, 357 - rows, 358 - alignments, 359 - }), 492 + Self::Table { headers, rows, alignments, .. } => Block::Table(Table { headers, rows, alignments }), 493 + Self::Admonition { admonition_type, title, blocks } => { 494 + Block::Admonition(Admonition { admonition_type, title, blocks }) 495 + } 360 496 } 361 497 } 362 498 } ··· 389 525 } 390 526 391 527 #[test] 528 + fn split_slides_ignores_separator_in_code_block() { 529 + let markdown = r#"# Slide 1 530 + 531 + ```markdown 532 + --- 533 + ``` 534 + 535 + Content after code block 536 + 537 + --- 538 + 539 + # Slide 2"#; 540 + let slides = split_slides(markdown); 541 + assert_eq!(slides.len(), 2); 542 + assert!(slides[0].contains("Slide 1")); 543 + assert!(slides[0].contains("---")); 544 + assert!(slides[0].contains("Content after code block")); 545 + assert!(slides[1].contains("Slide 2")); 546 + } 547 + 548 + #[test] 392 549 fn parse_heading() { 393 550 let slides = parse_slides("# Hello World").unwrap(); 394 551 assert_eq!(slides.len(), 1); ··· 548 705 } 549 706 _ => panic!("Expected table"), 550 707 } 708 + } 709 + 710 + #[test] 711 + fn preprocess_github_admonition() { 712 + let markdown = r#"> [!NOTE] 713 + > This is a note"#; 714 + let preprocessed = preprocess_admonitions(markdown); 715 + assert!(preprocessed.contains("<admonition")); 716 + assert!(preprocessed.contains("type=\"note\"")); 717 + assert!(preprocessed.contains("</admonition>")); 718 + 719 + let mut options = Options::empty(); 720 + options.insert(Options::ENABLE_TABLES); 721 + options.insert(Options::ENABLE_STRIKETHROUGH); 722 + let parser = Parser::new_ext(&preprocessed, options); 723 + let events: Vec<_> = parser.collect(); 724 + 725 + let has_html = events.iter().any(|e| matches!(e, Event::Html(_))); 726 + assert!(has_html, "Should have HTML events"); 727 + } 728 + 729 + #[test] 730 + fn parse_github_admonition_note() { 731 + let markdown = r#"> [!NOTE] 732 + > This is a note"#; 733 + let slides = parse_slides(markdown).unwrap(); 734 + assert_eq!(slides.len(), 1); 735 + 736 + match &slides[0].blocks[0] { 737 + Block::Admonition(admonition) => { 738 + assert_eq!(admonition.admonition_type, AdmonitionType::Note); 739 + assert_eq!(admonition.title, None); 740 + assert_eq!(admonition.blocks.len(), 1); 741 + } 742 + _ => panic!("Expected admonition, got: {:?}", slides[0].blocks[0]), 743 + } 744 + } 745 + 746 + #[test] 747 + fn parse_github_admonition_with_title() { 748 + let markdown = r#"> [!WARNING] Custom Warning 749 + > Be careful!"#; 750 + let slides = parse_slides(markdown).unwrap(); 751 + 752 + match &slides[0].blocks[0] { 753 + Block::Admonition(admonition) => { 754 + assert_eq!(admonition.admonition_type, AdmonitionType::Warning); 755 + assert_eq!(admonition.title, Some("Custom Warning".to_string())); 756 + assert_eq!(admonition.blocks.len(), 1); 757 + } 758 + _ => panic!("Expected admonition"), 759 + } 760 + } 761 + 762 + #[test] 763 + fn parse_fence_admonition() { 764 + let markdown = r#":::tip 765 + This is a helpful tip 766 + :::"#; 767 + let slides = parse_slides(markdown).unwrap(); 768 + assert_eq!(slides.len(), 1); 769 + 770 + match &slides[0].blocks[0] { 771 + Block::Admonition(admonition) => { 772 + assert_eq!(admonition.admonition_type, AdmonitionType::Tip); 773 + assert_eq!(admonition.blocks.len(), 1); 774 + } 775 + _ => panic!("Expected admonition"), 776 + } 777 + } 778 + 779 + #[test] 780 + fn parse_admonition_danger_alias() { 781 + let markdown = r#"> [!DANGER] 782 + > Dangerous content"#; 783 + let slides = parse_slides(markdown).unwrap(); 784 + 785 + match &slides[0].blocks[0] { 786 + Block::Admonition(admonition) => { 787 + assert_eq!(admonition.admonition_type, AdmonitionType::Danger); 788 + } 789 + _ => panic!("Expected admonition"), 790 + } 791 + } 792 + 793 + #[test] 794 + fn admonition_type_from_str_note() { 795 + assert_eq!("note".parse::<AdmonitionType>(), Ok(AdmonitionType::Note)); 796 + assert_eq!("NOTE".parse::<AdmonitionType>(), Ok(AdmonitionType::Note)); 797 + } 798 + 799 + #[test] 800 + fn admonition_type_from_str_tip_alias() { 801 + assert_eq!("tip".parse::<AdmonitionType>(), Ok(AdmonitionType::Tip)); 802 + assert_eq!("hint".parse::<AdmonitionType>(), Ok(AdmonitionType::Tip)); 803 + } 804 + 805 + #[test] 806 + fn admonition_type_from_str_warning_aliases() { 807 + assert_eq!("warning".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning)); 808 + assert_eq!("caution".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning)); 809 + assert_eq!("attention".parse::<AdmonitionType>(), Ok(AdmonitionType::Warning)); 810 + } 811 + 812 + #[test] 813 + fn admonition_type_from_str_invalid() { 814 + assert!("invalid".parse::<AdmonitionType>().is_err()); 815 + assert!("".parse::<AdmonitionType>().is_err()); 551 816 } 552 817 }
+223
core/src/printer.rs
··· 1 1 use crate::highlighter; 2 2 use crate::slide::{Block, CodeBlock, List, Table, TextSpan, TextStyle}; 3 3 use crate::theme::ThemeColors; 4 + use owo_colors::OwoColorize; 5 + use unicode_width::UnicodeWidthChar; 4 6 5 7 /// Print slides to stdout with formatted output 6 8 /// ··· 71 73 } 72 74 Block::Table(table) => { 73 75 print_table(writer, table, theme, width)?; 76 + } 77 + Block::Admonition(admonition) => { 78 + print_admonition(writer, admonition, theme, width, indent)?; 74 79 } 75 80 } 76 81 ··· 226 231 Ok(()) 227 232 } 228 233 234 + /// Print an admonition with icon, colored border, and title 235 + fn print_admonition<W: std::io::Write>( 236 + writer: &mut W, admonition: &crate::slide::Admonition, theme: &ThemeColors, width: usize, indent: usize, 237 + ) -> std::io::Result<()> { 238 + use crate::slide::AdmonitionType; 239 + 240 + let (icon, color, default_title) = match admonition.admonition_type { 241 + AdmonitionType::Note => ("\u{24D8}", &theme.admonition_note, "Note"), 242 + AdmonitionType::Tip => ("\u{1F4A1}", &theme.admonition_tip, "Tip"), 243 + AdmonitionType::Important => ("\u{2757}", &theme.admonition_tip, "Important"), 244 + AdmonitionType::Warning => ("\u{26A0}", &theme.admonition_warning, "Warning"), 245 + AdmonitionType::Caution => ("\u{26A0}", &theme.admonition_warning, "Caution"), 246 + AdmonitionType::Danger => ("\u{26D4}", &theme.admonition_danger, "Danger"), 247 + AdmonitionType::Error => ("\u{2717}", &theme.admonition_danger, "Error"), 248 + AdmonitionType::Info => ("\u{24D8}", &theme.admonition_info, "Info"), 249 + AdmonitionType::Success => ("\u{2713}", &theme.admonition_success, "Success"), 250 + AdmonitionType::Question => ("?", &theme.admonition_info, "Question"), 251 + AdmonitionType::Example => ("\u{25B8}", &theme.admonition_success, "Example"), 252 + AdmonitionType::Quote => ("\u{201C}", &theme.admonition_info, "Quote"), 253 + AdmonitionType::Abstract => ("\u{00A7}", &theme.admonition_note, "Abstract"), 254 + AdmonitionType::Todo => ("\u{2610}", &theme.admonition_info, "Todo"), 255 + AdmonitionType::Bug => ("\u{1F41B}", &theme.admonition_danger, "Bug"), 256 + AdmonitionType::Failure => ("\u{2717}", &theme.admonition_danger, "Failure"), 257 + }; 258 + 259 + let title = admonition.title.as_deref().unwrap_or(default_title); 260 + let indent_str = " ".repeat(indent); 261 + let box_width = width.saturating_sub(indent); 262 + 263 + let top_border = "\u{256D}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{256E}"; 264 + writeln!(writer, "{}{}", indent_str, color.to_owo_color(&top_border))?; 265 + 266 + let icon_display_width = icon.chars().next().and_then(|c| c.width()).unwrap_or(1); 267 + 268 + write!(writer, "{}{} ", indent_str, color.to_owo_color(&"\u{2502}"))?; 269 + write!(writer, "{icon} ")?; 270 + write!(writer, "{}", color.to_owo_color(&title).bold())?; 271 + 272 + let title_padding = box_width.saturating_sub(4 + icon_display_width + 1 + title.len()); 273 + write!(writer, "{}", " ".repeat(title_padding))?; 274 + writeln!(writer, " {}", color.to_owo_color(&"\u{2502}"))?; 275 + 276 + if !admonition.blocks.is_empty() { 277 + let separator = "\u{251C}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{2524}"; 278 + writeln!(writer, "{}{}", indent_str, color.to_owo_color(&separator))?; 279 + 280 + for block in &admonition.blocks { 281 + match block { 282 + Block::Paragraph { spans } => { 283 + print_wrapped_admonition_paragraph(writer, spans, theme, color, &indent_str, box_width)?; 284 + } 285 + _ => { 286 + write!(writer, "{}{} ", indent_str, color.to_owo_color(&"\u{2502}"))?; 287 + print_block(writer, block, theme, box_width.saturating_sub(4), indent + 2)?; 288 + writeln!(writer, "{}", color.to_owo_color(&"\u{2502}"))?; 289 + } 290 + } 291 + } 292 + } 293 + 294 + let bottom_border = "\u{2570}".to_string() + &"\u{2500}".repeat(box_width.saturating_sub(2)) + "\u{256F}"; 295 + writeln!(writer, "{}{}", indent_str, color.to_owo_color(&bottom_border))?; 296 + 297 + Ok(()) 298 + } 299 + 300 + /// Print a wrapped paragraph inside an admonition with proper text wrapping 301 + fn print_wrapped_admonition_paragraph<W: std::io::Write>( 302 + writer: &mut W, spans: &[TextSpan], theme: &ThemeColors, border_color: &crate::theme::Color, indent_str: &str, 303 + box_width: usize, 304 + ) -> std::io::Result<()> { 305 + let text = spans.iter().map(|s| s.text.as_str()).collect::<Vec<_>>().join(""); 306 + let words: Vec<&str> = text.split_whitespace().collect(); 307 + 308 + let content_width = box_width.saturating_sub(4); 309 + let mut current_line = String::new(); 310 + 311 + for word in words { 312 + if current_line.is_empty() { 313 + current_line = word.to_string(); 314 + } else if current_line.len() + 1 + word.len() <= content_width { 315 + current_line.push(' '); 316 + current_line.push_str(word); 317 + } else { 318 + write!(writer, "{}{} ", indent_str, border_color.to_owo_color(&"\u{2502}"))?; 319 + write!(writer, "{}", theme.body(&current_line))?; 320 + let padding = content_width.saturating_sub(current_line.len()); 321 + write!(writer, "{}", " ".repeat(padding))?; 322 + writeln!(writer, "{}", border_color.to_owo_color(&"\u{2502}"))?; 323 + current_line = word.to_string(); 324 + } 325 + } 326 + 327 + if !current_line.is_empty() { 328 + write!(writer, "{}{} ", indent_str, border_color.to_owo_color(&"\u{2502}"))?; 329 + write!(writer, "{}", theme.body(&current_line))?; 330 + let padding = content_width.saturating_sub(current_line.len()); 331 + write!(writer, "{}", " ".repeat(padding))?; 332 + writeln!(writer, "{}", border_color.to_owo_color(&"\u{2502}"))?; 333 + } 334 + 335 + Ok(()) 336 + } 337 + 229 338 /// Print a table with borders and proper column width calculation 230 339 /// 231 340 /// Calculates column widths based on content and distributes available space ··· 547 656 548 657 assert!(separator.contains("─┼─")); 549 658 assert!(separator.contains("─")); 659 + } 660 + 661 + #[test] 662 + fn print_admonition_with_wrapping() { 663 + use crate::slide::{Admonition, AdmonitionType}; 664 + 665 + let admonition = Admonition { 666 + admonition_type: AdmonitionType::Tip, 667 + title: Some("Tip".to_string()), 668 + blocks: vec![Block::Paragraph { 669 + spans: vec![TextSpan::plain( 670 + "Variables are immutable by default - use mut only when you need to change values", 671 + )], 672 + }], 673 + }; 674 + 675 + let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]); 676 + let theme = ThemeColors::default(); 677 + let mut output = Vec::new(); 678 + 679 + let result = print_slides(&mut output, &[slide], &theme, 80); 680 + assert!(result.is_ok()); 681 + 682 + let text = String::from_utf8_lossy(&output); 683 + assert!(text.contains("Tip")); 684 + assert!(text.contains("Variables are immutable")); 685 + assert!(text.contains("mut")); 686 + assert!(text.contains("╭") && text.contains("╮")); 687 + assert!(text.contains("├") && text.contains("┤")); 688 + assert!(text.contains("╰") && text.contains("╯")); 689 + assert!(text.contains("│")); 690 + } 691 + 692 + #[test] 693 + fn print_admonition_border_length() { 694 + use crate::slide::{Admonition, AdmonitionType}; 695 + 696 + let admonition = Admonition { 697 + admonition_type: AdmonitionType::Note, 698 + title: None, 699 + blocks: vec![Block::Paragraph { spans: vec![TextSpan::plain("Test content")] }], 700 + }; 701 + 702 + let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]); 703 + let theme = ThemeColors::default(); 704 + let mut output = Vec::new(); 705 + 706 + let width = 60; 707 + let result = print_slides(&mut output, &[slide], &theme, width); 708 + assert!(result.is_ok()); 709 + 710 + let text = String::from_utf8_lossy(&output); 711 + let lines: Vec<&str> = text.lines().collect(); 712 + 713 + for line in &lines { 714 + if line.contains("╭") || line.contains("├") || line.contains("╰") { 715 + let stripped = strip_ansi_codes(line); 716 + let visible_len = stripped.chars().count(); 717 + assert!( 718 + visible_len <= width, 719 + "Border line too long: {visible_len} chars (max {width})\nLine: {stripped}" 720 + ); 721 + } 722 + } 723 + } 724 + 725 + fn strip_ansi_codes(s: &str) -> String { 726 + let mut result = String::new(); 727 + let mut chars = s.chars().peekable(); 728 + 729 + while let Some(c) = chars.next() { 730 + if c == '\x1b' { 731 + if chars.peek() == Some(&'[') { 732 + chars.next(); 733 + for ch in chars.by_ref() { 734 + if ch.is_ascii_alphabetic() { 735 + break; 736 + } 737 + } 738 + } 739 + } else { 740 + result.push(c); 741 + } 742 + } 743 + 744 + result 745 + } 746 + 747 + #[test] 748 + fn print_admonition_wraps_long_text() { 749 + use crate::slide::{Admonition, AdmonitionType}; 750 + 751 + let long_text = "This is a very long text that should definitely wrap across multiple lines when rendered in a narrow width to ensure readability and proper formatting"; 752 + 753 + let admonition = Admonition { 754 + admonition_type: AdmonitionType::Warning, 755 + title: Some("Warning".to_string()), 756 + blocks: vec![Block::Paragraph { spans: vec![TextSpan::plain(long_text)] }], 757 + }; 758 + 759 + let slide = Slide::with_blocks(vec![Block::Admonition(admonition)]); 760 + let theme = ThemeColors::default(); 761 + let mut output = Vec::new(); 762 + 763 + let result = print_slides(&mut output, &[slide], &theme, 50); 764 + assert!(result.is_ok()); 765 + 766 + let text = String::from_utf8_lossy(&output); 767 + let content_lines: Vec<&str> = text 768 + .lines() 769 + .filter(|line| line.contains("│") && !line.contains("╭") && !line.contains("├") && !line.contains("╰")) 770 + .collect(); 771 + 772 + assert!(content_lines.len() > 2, "Long text should wrap to multiple lines"); 550 773 } 551 774 }
+73
core/src/slide.rs
··· 1 + use std::str::FromStr; 2 + 1 3 use serde::{Deserialize, Serialize}; 2 4 3 5 /// A single slide in a presentation ··· 46 48 BlockQuote { blocks: Vec<Block> }, 47 49 /// Table 48 50 Table(Table), 51 + /// Admonition/alert box with type, optional title, and content 52 + Admonition(Admonition), 49 53 } 50 54 51 55 /// Styled text span within a block ··· 128 132 Left, 129 133 Center, 130 134 Right, 135 + } 136 + 137 + /// Admonition type determines styling and icon 138 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 139 + #[serde(rename_all = "lowercase")] 140 + pub enum AdmonitionType { 141 + Note, 142 + Tip, 143 + Important, 144 + Warning, 145 + Caution, 146 + Danger, 147 + Error, 148 + Info, 149 + Success, 150 + Question, 151 + Example, 152 + Quote, 153 + Abstract, 154 + Todo, 155 + Bug, 156 + Failure, 157 + } 158 + 159 + /// Error type for parsing AdmonitionType 160 + #[derive(Debug, Clone, PartialEq, Eq)] 161 + pub struct ParseAdmonitionTypeError; 162 + 163 + impl std::fmt::Display for ParseAdmonitionTypeError { 164 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 165 + write!(f, "invalid admonition type") 166 + } 167 + } 168 + 169 + impl std::error::Error for ParseAdmonitionTypeError {} 170 + 171 + impl FromStr for AdmonitionType { 172 + type Err = ParseAdmonitionTypeError; 173 + 174 + /// Parse admonition type from string (case-insensitive) 175 + /// 176 + /// Supports GitHub and Obsidian aliases 177 + fn from_str(s: &str) -> Result<Self, Self::Err> { 178 + match s.to_lowercase().as_str() { 179 + "note" => Ok(Self::Note), 180 + "tip" | "hint" => Ok(Self::Tip), 181 + "important" => Ok(Self::Important), 182 + "warning" | "caution" | "attention" => Ok(Self::Warning), 183 + "danger" | "error" => Ok(Self::Danger), 184 + "info" => Ok(Self::Info), 185 + "success" | "check" | "done" => Ok(Self::Success), 186 + "question" | "help" | "faq" => Ok(Self::Question), 187 + "example" => Ok(Self::Example), 188 + "quote" => Ok(Self::Quote), 189 + "abstract" | "summary" | "tldr" => Ok(Self::Abstract), 190 + "todo" => Ok(Self::Todo), 191 + "bug" => Ok(Self::Bug), 192 + "failure" | "fail" | "missing" => Ok(Self::Failure), 193 + _ => Err(ParseAdmonitionTypeError), 194 + } 195 + } 196 + } 197 + 198 + /// Admonition/alert box with styled content 199 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 200 + pub struct Admonition { 201 + pub admonition_type: AdmonitionType, 202 + pub title: Option<String>, 203 + pub blocks: Vec<Block>, 131 204 } 132 205 133 206 #[cfg(test)]
+25 -11
core/src/term.rs
··· 1 + use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; 2 + use std::{io, time::Duration}; 3 + 4 + #[cfg(not(test))] 1 5 use crossterm::{ 2 - event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, 3 6 execute, 4 7 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 5 8 }; 6 - use std::{io, time::Duration}; 7 9 8 10 /// Terminal manager that handles setup and cleanup 9 11 /// ··· 25 27 /// 26 28 /// Enables alternate screen and raw mode for full terminal control. 27 29 pub fn setup() -> io::Result<Self> { 28 - let mut stdout = io::stdout(); 29 - execute!(stdout, EnterAlternateScreen)?; 30 - enable_raw_mode()?; 30 + #[cfg(not(test))] 31 + { 32 + let mut stdout = io::stdout(); 33 + execute!(stdout, EnterAlternateScreen)?; 34 + enable_raw_mode()?; 35 + } 31 36 32 37 Ok(Self::default()) 33 38 } ··· 36 41 /// 37 42 /// Called automatically on drop, but can be called manually for explicit cleanup. 38 43 pub fn restore(&mut self) -> io::Result<()> { 39 - if self.in_raw_mode { 40 - disable_raw_mode()?; 41 - self.in_raw_mode = false; 44 + #[cfg(not(test))] 45 + { 46 + if self.in_raw_mode { 47 + disable_raw_mode()?; 48 + self.in_raw_mode = false; 49 + } 50 + 51 + if self.in_alternate_screen { 52 + let mut stdout = io::stdout(); 53 + execute!(stdout, LeaveAlternateScreen)?; 54 + self.in_alternate_screen = false; 55 + } 42 56 } 43 57 44 - if self.in_alternate_screen { 45 - let mut stdout = io::stdout(); 46 - execute!(stdout, LeaveAlternateScreen)?; 58 + #[cfg(test)] 59 + { 60 + self.in_raw_mode = false; 47 61 self.in_alternate_screen = false; 48 62 } 49 63
+19
core/src/theme.rs
··· 147 147 pub ui_title: Color, 148 148 pub ui_text: Color, 149 149 pub ui_background: Color, 150 + pub admonition_note: Color, 151 + pub admonition_tip: Color, 152 + pub admonition_warning: Color, 153 + pub admonition_danger: Color, 154 + pub admonition_success: Color, 155 + pub admonition_info: Color, 150 156 } 151 157 152 158 impl Default for ThemeColors { ··· 202 208 let ui_title = parse_hex_color(&palette.base06)?; 203 209 let ui_text = parse_hex_color(&palette.base05)?; 204 210 211 + let admonition_note = parse_hex_color(&palette.base0d)?; 212 + let admonition_tip = parse_hex_color(&palette.base0e)?; 213 + let admonition_warning = parse_hex_color(&palette.base0a)?; 214 + let admonition_danger = parse_hex_color(&palette.base08)?; 215 + let admonition_success = parse_hex_color(&palette.base0b)?; 216 + let admonition_info = parse_hex_color(&palette.base0c)?; 217 + 205 218 Some(Self { 206 219 heading: Color::new(heading.0, heading.1, heading.2), 207 220 heading_bold: true, ··· 222 235 ui_title: Color::new(ui_title.0, ui_title.1, ui_title.2), 223 236 ui_text: Color::new(ui_text.0, ui_text.1, ui_text.2), 224 237 ui_background: Color::new(ui_background.0, ui_background.1, ui_background.2), 238 + admonition_note: Color::new(admonition_note.0, admonition_note.1, admonition_note.2), 239 + admonition_tip: Color::new(admonition_tip.0, admonition_tip.1, admonition_tip.2), 240 + admonition_warning: Color::new(admonition_warning.0, admonition_warning.1, admonition_warning.2), 241 + admonition_danger: Color::new(admonition_danger.0, admonition_danger.1, admonition_danger.2), 242 + admonition_success: Color::new(admonition_success.0, admonition_success.1, admonition_success.2), 243 + admonition_info: Color::new(admonition_info.0, admonition_info.1, admonition_info.2), 225 244 }) 226 245 } 227 246
+133
docs/src/extensions.md
··· 1 + # Extensions 2 + 3 + ## Admonitions/Alerts 4 + 5 + Admonitions (also called alerts or callouts) are special highlighted blocks that draw attention to important information. Lantern supports both GitHub-flavored markdown syntax and a custom fence syntax. 6 + 7 + ### Supported Types 8 + 9 + All admonitions are rendered with colored borders and icons: 10 + 11 + - **Note/Info** - Blue - General information 12 + - **Tip/Hint/Important** - Purple - Helpful suggestions 13 + - **Warning/Caution/Attention** - Yellow - Important warnings 14 + - **Danger/Error** - Red - Critical issues 15 + - **Success/Check/Done** - Green - Success messages 16 + - **Question/Help/FAQ** - Cyan - Questions and help 17 + - **Example** - Green - Example content 18 + - **Quote** - Cyan - Quotations 19 + - **Abstract/Summary/TLDR** - Blue - Summaries 20 + - **Todo** - Cyan - Todo items 21 + - **Bug** - Red - Bug reports 22 + - **Failure/Fail/Missing** - Red - Failures 23 + 24 + ### GitHub/Obsidian Syntax 25 + 26 + ```markdown 27 + > [!NOTE] 28 + > Useful information that users should know, even when skimming content. 29 + 30 + > [!TIP] 31 + > Helpful advice for doing things better or more easily. 32 + 33 + > [!IMPORTANT] 34 + > Key information users need to know to achieve their goal. 35 + 36 + > [!WARNING] 37 + > Urgent info that needs immediate user attention to avoid problems. 38 + 39 + > [!CAUTION] 40 + > Advises about risks or negative outcomes of certain actions. 41 + ``` 42 + 43 + ### Obsidian 44 + 45 + ```markdown 46 + > [!quote] 47 + > Lorem ipsum dolor sit amet 48 + 49 + > [!quote] Optional Title 50 + > Lorem ipsum dolor sit amet 51 + 52 + > [!example] 53 + > Lorem ipsum dolor sit amet 54 + 55 + > [!bug] 56 + > Lorem ipsum dolor sit amet 57 + 58 + > [!danger] 59 + > Lorem ipsum dolor sit amet 60 + 61 + > [!failure] 62 + > Lorem ipsum dolor sit amet 63 + 64 + > [!warning] 65 + > Lorem ipsum dolor sit amet 66 + 67 + > [!question] 68 + > Lorem ipsum dolor sit amet 69 + 70 + > [!success] 71 + > Lorem ipsum dolor sit amet 72 + 73 + > [!tip] 74 + > Lorem ipsum dolor sit amet 75 + 76 + > [!todo] 77 + > Lorem ipsum dolor sit amet 78 + 79 + > [!abstract] 80 + > Lorem ipsum dolor sit amet 81 + 82 + > [!note] 83 + > Lorem ipsum dolor sit amet 84 + ``` 85 + 86 + #### Aliases 87 + 88 + | Main | Alias | 89 + | -------- | ------------------ | 90 + | danger | error | 91 + | failure | fail, missing | 92 + | warning | caution, attention | 93 + | question | help, faq | 94 + | success | check, done | 95 + | tip | hint, important | 96 + | abstract | summary, tldr | 97 + 98 + ### Fence Syntax 99 + 100 + You can also use a custom fence syntax with `:::`: 101 + 102 + ```markdown 103 + :::note 104 + This is a note using fence syntax 105 + ::: 106 + 107 + :::warning 108 + This is a warning with fence syntax 109 + ::: 110 + 111 + :::tip 112 + Pro tip: You can use either syntax! 113 + ::: 114 + ``` 115 + 116 + ### Custom Titles 117 + 118 + For GitHub/Obsidian syntax, you can provide a custom title: 119 + 120 + ```markdown 121 + > [!WARNING] Custom Warning Title 122 + > This warning has a custom title instead of the default "Warning" 123 + ``` 124 + 125 + ### Implementation Details 126 + 127 + Admonitions are: 128 + 129 + - Parsed during markdown preprocessing 130 + - Converted to internal AST representation 131 + - Rendered with themed colors from the active color scheme 132 + - Displayed with Unicode icons (ⓘ, ⚠, ✓, etc.) 133 + - Support nested markdown content (paragraphs, lists, code, etc.)
-1
docs/src/quickstart.md
··· 80 80 81 81 - `→`, `j`, `Space`, `n` - Next slide 82 82 - `←`, `k`, `p` - Previous slide 83 - - `0-9` - Jump to slide (single digit) 84 83 - `Shift+N` - Toggle speaker notes 85 84 - `q`, `Ctrl+C`, `Esc` - Quit presentation 86 85
+449
examples/learn-markdown.md
··· 1 + --- 2 + theme: default 3 + author: Learn Markdown 4 + --- 5 + 6 + # Markdown Basics 7 + 8 + A quick reference for Markdown syntax 9 + 10 + --- 11 + 12 + ## Headings 13 + 14 + Markdown supports multiple heading styles: 15 + 16 + ```markdown 17 + # This is an h1 18 + ## This is an h2 19 + ### This is an h3 20 + #### This is an h4 21 + ##### This is an h5 22 + ###### This is an h6 23 + ``` 24 + 25 + Alternative syntax for h1 and h2: 26 + 27 + ```markdown 28 + This is an h1 29 + ============= 30 + 31 + This is an h2 32 + ------------- 33 + ``` 34 + 35 + --- 36 + 37 + ## Text Formatting 38 + 39 + **Bold text:** 40 + 41 + ```markdown 42 + **This text is in bold.** 43 + __And so is this text.__ 44 + ``` 45 + 46 + *Italic text:* 47 + 48 + ```markdown 49 + *This text is in italics.* 50 + _And so is this text._ 51 + ``` 52 + 53 + Combined: 54 + 55 + ```markdown 56 + ***This text is in both.*** 57 + **_As is this!_** 58 + *__And this!__* 59 + ``` 60 + 61 + Strikethrough: 62 + 63 + ```markdown 64 + ~~This text is rendered with strikethrough.~~ 65 + ``` 66 + 67 + --- 68 + 69 + ## Paragraphs 70 + 71 + Paragraphs are separated by blank lines: 72 + 73 + ```markdown 74 + This is a paragraph. I'm typing in a paragraph. 75 + 76 + Now I'm in paragraph 2. 77 + I'm still in paragraph 2 too! 78 + 79 + I'm in paragraph three! 80 + ``` 81 + 82 + Line breaks require two spaces at the end or `<br />`: 83 + 84 + ```markdown 85 + I end with two spaces (highlight to see them). 86 + There's a <br /> above me! 87 + ``` 88 + 89 + --- 90 + 91 + ## Block Quotes 92 + 93 + Use `>` to create block quotes: 94 + 95 + ```markdown 96 + > This is a block quote. You can either 97 + > manually wrap your lines and put a `>` 98 + > before every line or you can let your 99 + > lines get really long and wrap on their own. 100 + ``` 101 + 102 + Nested quotes: 103 + 104 + ```markdown 105 + > You can also use more than one level 106 + >> of indentation? 107 + > How neat is that? 108 + ``` 109 + 110 + --- 111 + 112 + ## Lists 113 + 114 + **Unordered lists** use `*`, `+`, or `-`: 115 + 116 + ```markdown 117 + * Item 118 + * Item 119 + * Another item 120 + 121 + - Item 122 + - Item 123 + - One last item 124 + ``` 125 + 126 + **Ordered lists** use numbers: 127 + 128 + ```markdown 129 + 1. Item one 130 + 2. Item two 131 + 3. Item three 132 + ``` 133 + 134 + Nested lists: 135 + 136 + ```markdown 137 + 1. Item one 138 + 2. Item two 139 + 3. Item three 140 + * Sub-item 141 + * Sub-item 142 + 4. Item four 143 + ``` 144 + 145 + --- 146 + 147 + ## Task Lists 148 + 149 + Create checkboxes with `[ ]` and `[x]`: 150 + 151 + ```markdown 152 + - [ ] First task to complete 153 + - [ ] Second task that needs done 154 + - [x] This task has been completed 155 + ``` 156 + 157 + > [!NOTE] 158 + > Task lists are a GitHub-flavored Markdown extension 159 + 160 + --- 161 + 162 + ## Code 163 + 164 + **Inline code** uses backticks: 165 + 166 + ```markdown 167 + John didn't even know what the `go_to()` function did! 168 + ``` 169 + 170 + **Code blocks** use triple backticks or indentation: 171 + 172 + ````markdown 173 + ```rust 174 + fn main() { 175 + println!("Hello, world!"); 176 + } 177 + ``` 178 + 179 + This is code 180 + So is this 181 + ```` 182 + 183 + --- 184 + 185 + ## Horizontal Rules 186 + 187 + Create horizontal rules with three or more: 188 + 189 + ```markdown 190 + *** 191 + --- 192 + - - - 193 + **************** 194 + ``` 195 + 196 + All render as: 197 + 198 + *** 199 + 200 + ___ 201 + 202 + - - - 203 + 204 + --- 205 + 206 + ## Links 207 + 208 + **Inline links:** 209 + 210 + ```markdown 211 + [Click me!](http://test.com/) 212 + [Click me!](http://test.com/ "Link to Test.com") 213 + [Go to music](/music/) 214 + ``` 215 + 216 + **Reference links:** 217 + 218 + ```markdown 219 + [Click this link][link1] for more info! 220 + [Also check out this link][foobar] if you want. 221 + 222 + [link1]: http://test.com/ "Cool!" 223 + [foobar]: http://foobar.biz/ "Alright!" 224 + ``` 225 + 226 + **Implicit reference:** 227 + 228 + ```markdown 229 + [This][] is a link. 230 + 231 + [This]: http://thisisalink.com/ 232 + ``` 233 + 234 + --- 235 + 236 + ## Internal Links 237 + 238 + Link to headings using slugified IDs: 239 + 240 + ```markdown 241 + - [Heading](#heading) 242 + - [Another heading](#another-heading) 243 + - [Chapter](#chapter) 244 + - [Subchapter <h3 />](#subchapter-h3-) 245 + ``` 246 + 247 + > [!TIP] 248 + > Heading IDs are created by lowercasing and replacing spaces with hyphens 249 + 250 + --- 251 + 252 + ## Images 253 + 254 + **Inline images:** 255 + 256 + ```markdown 257 + ![Alt text for image](http://imgur.com/myimage.jpg "Optional title") 258 + ``` 259 + 260 + **Reference images:** 261 + 262 + ```markdown 263 + ![This is the alt-attribute.][myimage] 264 + 265 + [myimage]: relative/urls/cool/image.jpg "Optional title" 266 + ``` 267 + 268 + > [!NOTE] 269 + > Images use the same syntax as links, but with a `!` prefix 270 + 271 + --- 272 + 273 + ## Automatic Links 274 + 275 + URLs and email addresses can be auto-linked: 276 + 277 + ```markdown 278 + <http://testwebsite.com/> 279 + <foo@bar.com> 280 + ``` 281 + 282 + These are equivalent to: 283 + 284 + ```markdown 285 + [http://testwebsite.com/](http://testwebsite.com/) 286 + [foo@bar.com](mailto:foo@bar.com) 287 + ``` 288 + 289 + --- 290 + 291 + ## Escaping 292 + 293 + Use backslash to escape special characters: 294 + 295 + ```markdown 296 + I want to type *this* but not in italics: 297 + \*this text surrounded by asterisks\* 298 + ``` 299 + 300 + Special characters you can escape: 301 + 302 + ```markdown 303 + \ backslash 304 + ` backtick 305 + * asterisk 306 + _ underscore 307 + {} curly braces 308 + [] square brackets 309 + () parentheses 310 + # hash mark 311 + + plus sign 312 + - minus sign 313 + . dot 314 + ! exclamation mark 315 + ``` 316 + 317 + --- 318 + 319 + ## HTML Elements 320 + 321 + You can use HTML in Markdown: 322 + 323 + ```markdown 324 + Your computer crashed? Try sending a 325 + <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>Del</kbd> 326 + ``` 327 + 328 + > [!WARNING] 329 + > You cannot use Markdown syntax within HTML element contents 330 + 331 + --- 332 + 333 + ## Tables 334 + 335 + Create tables with pipes and hyphens: 336 + 337 + ```markdown 338 + | Col1 | Col2 | Col3 | 339 + | :----------- | :------: | ------------: | 340 + | Left-aligned | Centered | Right-aligned | 341 + | blah | blah | blah | 342 + ``` 343 + 344 + Compact syntax also works: 345 + 346 + ```markdown 347 + Col 1 | Col2 | Col3 348 + :-- | :-: | --: 349 + Ugh this is ugly | make it | stop 350 + ``` 351 + 352 + Alignment is controlled by colons: 353 + 354 + - `:--` = left-aligned 355 + - `:-:` = centered 356 + - `--:` = right-aligned 357 + 358 + --- 359 + 360 + ## Admonitions 361 + 362 + > [!IMPORTANT] 363 + > Admonitions are NOT standard Markdown - they are an extension 364 + 365 + Common admonition types: 366 + 367 + ```markdown 368 + > [!NOTE] 369 + > Useful information 370 + 371 + > [!TIP] 372 + > Helpful advice 373 + 374 + > [!IMPORTANT] 375 + > Critical information 376 + 377 + > [!WARNING] 378 + > Proceed with caution 379 + 380 + > [!CAUTION] 381 + > Potential risks ahead 382 + ``` 383 + 384 + --- 385 + 386 + ## Admonition Examples 387 + 388 + > [!NOTE] 389 + > This is a note admonition with helpful context 390 + 391 + > [!TIP] 392 + > Use Markdown for clear, readable documentation 393 + 394 + > [!WARNING] 395 + > Not all Markdown processors support the same features 396 + 397 + > [!IMPORTANT] 398 + > Always check your Markdown processor's documentation for supported features 399 + 400 + --- 401 + 402 + ## Comments 403 + 404 + HTML comments work in Markdown: 405 + 406 + ```markdown 407 + <!-- This is a comment and won't be rendered --> 408 + ``` 409 + 410 + Comments are useful for: 411 + 412 + - Leaving notes for yourself or collaborators 413 + - Temporarily hiding content 414 + - Adding metadata that shouldn't display 415 + 416 + --- 417 + 418 + ## Summary 419 + 420 + Markdown provides: 421 + 422 + - **Simple syntax** for formatted text 423 + - **Readable source** that looks good even as plain text 424 + - **Portable format** supported by many tools 425 + - **Extensions** like tables, task lists, and admonitions 426 + 427 + > [!SUCCESS] 428 + > You now know the basics of Markdown! 429 + 430 + --- 431 + 432 + ## Resources 433 + 434 + **Learn more:** 435 + 436 + - Markdown Guide (https://www.markdownguide.org/) 437 + - GitHub Flavored Markdown (https://github.github.com/gfm/) 438 + - CommonMark Spec (https://commonmark.org/) 439 + 440 + **Practice:** 441 + 442 + - Markdown Tutorial (https://www.markdowntutorial.com/) 443 + - Dillinger (https://dillinger.io/) - Online Markdown editor 444 + 445 + --- 446 + 447 + ## Thank You 448 + 449 + Happy writing!
+9 -2
examples/learn-rust.md
··· 66 66 mutable += 2; 67 67 ``` 68 68 69 + > [!TIP] 70 + > Variables are immutable by default - use `mut` only when you need to change values 71 + 69 72 --- 70 73 71 74 ## Numbers ··· 405 408 // Now var2 can be used again 406 409 ``` 407 410 408 - **Key rule:** Either many immutable references OR one mutable reference. 411 + > [!WARNING] 412 + > Only ONE mutable reference OR many immutable references at a time 409 413 410 414 --- 411 415 412 416 ## Memory Safety 413 417 414 - Rust's borrow checker ensures: 418 + > [!IMPORTANT] 419 + > Rust's borrow checker ensures memory safety at compile time 420 + 421 + Guarantees: 415 422 416 423 - No use after free 417 424 - No double free
+1
ui/Cargo.toml
··· 8 8 crossterm = "0.29.0" 9 9 lantern-core = { path = "../core" } 10 10 owo-colors = "4.2.3" 11 + unicode-width = "0.2"
+92
ui/src/renderer.rs
··· 7 7 style::{Modifier, Style}, 8 8 text::{Line, Span, Text}, 9 9 }; 10 + use unicode_width::UnicodeWidthChar; 10 11 11 12 /// Render a slide's blocks into ratatui Text 12 13 /// ··· 23 24 Block::Rule => render_rule(theme, &mut lines), 24 25 Block::BlockQuote { blocks } => render_blockquote(blocks, theme, &mut lines), 25 26 Block::Table(table) => render_table(table, theme, &mut lines), 27 + Block::Admonition(admonition) => render_admonition(admonition, theme, &mut lines), 26 28 } 27 29 28 30 lines.push(Line::raw("")); ··· 133 135 lines.push(Line::from(line_spans)); 134 136 } 135 137 } 138 + } 139 + 140 + /// Render an admonition with colored border and icon 141 + fn render_admonition( 142 + admonition: &lantern_core::slide::Admonition, theme: &ThemeColors, lines: &mut Vec<Line<'static>>, 143 + ) { 144 + use lantern_core::slide::AdmonitionType; 145 + 146 + let (icon, color, default_title) = match admonition.admonition_type { 147 + AdmonitionType::Note => ("\u{24D8}", &theme.admonition_note, "Note"), 148 + AdmonitionType::Tip => ("\u{1F4A1}", &theme.admonition_tip, "Tip"), 149 + AdmonitionType::Important => ("\u{2757}", &theme.admonition_tip, "Important"), 150 + AdmonitionType::Warning => ("\u{26A0}", &theme.admonition_warning, "Warning"), 151 + AdmonitionType::Caution => ("\u{26A0}", &theme.admonition_warning, "Caution"), 152 + AdmonitionType::Danger => ("\u{26D4}", &theme.admonition_danger, "Danger"), 153 + AdmonitionType::Error => ("\u{2717}", &theme.admonition_danger, "Error"), 154 + AdmonitionType::Info => ("\u{24D8}", &theme.admonition_info, "Info"), 155 + AdmonitionType::Success => ("\u{2713}", &theme.admonition_success, "Success"), 156 + AdmonitionType::Question => ("?", &theme.admonition_info, "Question"), 157 + AdmonitionType::Example => ("\u{25B8}", &theme.admonition_success, "Example"), 158 + AdmonitionType::Quote => ("\u{201C}", &theme.admonition_info, "Quote"), 159 + AdmonitionType::Abstract => ("\u{00A7}", &theme.admonition_note, "Abstract"), 160 + AdmonitionType::Todo => ("\u{2610}", &theme.admonition_info, "Todo"), 161 + AdmonitionType::Bug => ("\u{1F41B}", &theme.admonition_danger, "Bug"), 162 + AdmonitionType::Failure => ("\u{2717}", &theme.admonition_danger, "Failure"), 163 + }; 164 + 165 + let title = admonition.title.as_deref().unwrap_or(default_title); 166 + let color_style = to_ratatui_style(color, false); 167 + let bold_color_style = to_ratatui_style(color, true); 168 + 169 + let top_border = format!("\u{256D}{}\u{256E}", "\u{2500}".repeat(58)); 170 + lines.push(Line::from(Span::styled(top_border, color_style))); 171 + 172 + let icon_display_width = icon.chars().next().and_then(|c| c.width()).unwrap_or(1); 173 + 174 + let title_line = vec![ 175 + Span::styled("\u{2502} ".to_string(), color_style), 176 + Span::raw(format!("{icon} ")), 177 + Span::styled(title.to_string(), bold_color_style), 178 + Span::styled( 179 + " ".repeat(56_usize.saturating_sub(icon_display_width + 1 + title.len())), 180 + color_style, 181 + ), 182 + Span::styled(" \u{2502}".to_string(), color_style), 183 + ]; 184 + lines.push(Line::from(title_line)); 185 + 186 + if !admonition.blocks.is_empty() { 187 + let separator = format!("\u{251C}{}\u{2524}", "\u{2500}".repeat(58)); 188 + lines.push(Line::from(Span::styled(separator, color_style))); 189 + 190 + for block in &admonition.blocks { 191 + if let Block::Paragraph { spans } = block { 192 + let text: String = spans.iter().map(|s| s.text.as_str()).collect(); 193 + let words: Vec<&str> = text.split_whitespace().collect(); 194 + let content_width = 56; // 60 total - 2 for borders - 2 for spaces 195 + 196 + let mut current_line = String::new(); 197 + for word in words { 198 + if current_line.is_empty() { 199 + current_line = word.to_string(); 200 + } else if current_line.len() + 1 + word.len() <= content_width { 201 + current_line.push(' '); 202 + current_line.push_str(word); 203 + } else { 204 + let mut line_spans = vec![Span::styled("\u{2502} ".to_string(), color_style)]; 205 + line_spans.push(Span::raw(current_line.clone())); 206 + let padding = content_width.saturating_sub(current_line.len()); 207 + line_spans.push(Span::raw(" ".repeat(padding))); 208 + line_spans.push(Span::styled(" \u{2502}".to_string(), color_style)); 209 + lines.push(Line::from(line_spans)); 210 + current_line = word.to_string(); 211 + } 212 + } 213 + 214 + if !current_line.is_empty() { 215 + let mut line_spans = vec![Span::styled("\u{2502} ".to_string(), color_style)]; 216 + line_spans.push(Span::raw(current_line.clone())); 217 + let padding = content_width.saturating_sub(current_line.len()); 218 + line_spans.push(Span::raw(" ".repeat(padding))); 219 + line_spans.push(Span::styled(" \u{2502}".to_string(), color_style)); 220 + lines.push(Line::from(line_spans)); 221 + } 222 + } 223 + } 224 + } 225 + 226 + let bottom_border = format!("\u{2570}{}\u{256F}", "\u{2500}".repeat(58)); 227 + lines.push(Line::from(Span::styled(bottom_border, color_style))); 136 228 } 137 229 138 230 /// Render a table with basic formatting