magical markdown slides
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: project plumbing

+1158 -7
+210
Cargo.lock
··· 59 59 ] 60 60 61 61 [[package]] 62 + name = "anyhow" 63 + version = "1.0.100" 64 + source = "registry+https://github.com/rust-lang/crates.io-index" 65 + checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 66 + 67 + [[package]] 62 68 name = "bitflags" 63 69 version = "2.9.4" 64 70 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 297 303 checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 298 304 299 305 [[package]] 306 + name = "getopts" 307 + version = "0.2.24" 308 + source = "registry+https://github.com/rust-lang/crates.io-index" 309 + checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" 310 + dependencies = [ 311 + "unicode-width 0.2.0", 312 + ] 313 + 314 + [[package]] 300 315 name = "hashbrown" 301 316 version = "0.15.5" 302 317 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 318 333 version = "1.0.1" 319 334 source = "registry+https://github.com/rust-lang/crates.io-index" 320 335 checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 336 + 337 + [[package]] 338 + name = "indexmap" 339 + version = "2.11.4" 340 + source = "registry+https://github.com/rust-lang/crates.io-index" 341 + checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" 342 + dependencies = [ 343 + "equivalent", 344 + "hashbrown", 345 + ] 321 346 322 347 [[package]] 323 348 name = "indoc" ··· 372 397 checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" 373 398 374 399 [[package]] 400 + name = "libyml" 401 + version = "0.0.5" 402 + source = "registry+https://github.com/rust-lang/crates.io-index" 403 + checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" 404 + dependencies = [ 405 + "anyhow", 406 + "version_check", 407 + ] 408 + 409 + [[package]] 375 410 name = "linux-raw-sys" 376 411 version = "0.4.15" 377 412 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 412 447 dependencies = [ 413 448 "hashbrown", 414 449 ] 450 + 451 + [[package]] 452 + name = "memchr" 453 + version = "2.7.6" 454 + source = "registry+https://github.com/rust-lang/crates.io-index" 455 + checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 415 456 416 457 [[package]] 417 458 name = "mio" ··· 497 538 ] 498 539 499 540 [[package]] 541 + name = "pulldown-cmark" 542 + version = "0.13.0" 543 + source = "registry+https://github.com/rust-lang/crates.io-index" 544 + checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" 545 + dependencies = [ 546 + "bitflags", 547 + "getopts", 548 + "memchr", 549 + "pulldown-cmark-escape", 550 + "unicase", 551 + ] 552 + 553 + [[package]] 554 + name = "pulldown-cmark-escape" 555 + version = "0.11.0" 556 + source = "registry+https://github.com/rust-lang/crates.io-index" 557 + checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" 558 + 559 + [[package]] 500 560 name = "quote" 501 561 version = "1.0.41" 502 562 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 580 640 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 581 641 582 642 [[package]] 643 + name = "serde" 644 + version = "1.0.228" 645 + source = "registry+https://github.com/rust-lang/crates.io-index" 646 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 647 + dependencies = [ 648 + "serde_core", 649 + "serde_derive", 650 + ] 651 + 652 + [[package]] 653 + name = "serde_core" 654 + version = "1.0.228" 655 + source = "registry+https://github.com/rust-lang/crates.io-index" 656 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 657 + dependencies = [ 658 + "serde_derive", 659 + ] 660 + 661 + [[package]] 662 + name = "serde_derive" 663 + version = "1.0.228" 664 + source = "registry+https://github.com/rust-lang/crates.io-index" 665 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 666 + dependencies = [ 667 + "proc-macro2", 668 + "quote", 669 + "syn", 670 + ] 671 + 672 + [[package]] 673 + name = "serde_json" 674 + version = "1.0.145" 675 + source = "registry+https://github.com/rust-lang/crates.io-index" 676 + checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" 677 + dependencies = [ 678 + "itoa", 679 + "memchr", 680 + "ryu", 681 + "serde", 682 + "serde_core", 683 + ] 684 + 685 + [[package]] 686 + name = "serde_spanned" 687 + version = "1.0.2" 688 + source = "registry+https://github.com/rust-lang/crates.io-index" 689 + checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" 690 + dependencies = [ 691 + "serde_core", 692 + ] 693 + 694 + [[package]] 695 + name = "serde_yml" 696 + version = "0.0.12" 697 + source = "registry+https://github.com/rust-lang/crates.io-index" 698 + checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" 699 + dependencies = [ 700 + "indexmap", 701 + "itoa", 702 + "libyml", 703 + "memchr", 704 + "ryu", 705 + "serde", 706 + "version_check", 707 + ] 708 + 709 + [[package]] 583 710 name = "sharded-slab" 584 711 version = "0.1.7" 585 712 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 636 763 dependencies = [ 637 764 "crossterm 0.29.0", 638 765 "owo-colors", 766 + "pulldown-cmark", 767 + "serde", 768 + "serde_json", 769 + "serde_yml", 770 + "thiserror", 771 + "toml", 639 772 "tracing", 640 773 ] 641 774 ··· 695 828 ] 696 829 697 830 [[package]] 831 + name = "thiserror" 832 + version = "2.0.17" 833 + source = "registry+https://github.com/rust-lang/crates.io-index" 834 + checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 835 + dependencies = [ 836 + "thiserror-impl", 837 + ] 838 + 839 + [[package]] 840 + name = "thiserror-impl" 841 + version = "2.0.17" 842 + source = "registry+https://github.com/rust-lang/crates.io-index" 843 + checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" 844 + dependencies = [ 845 + "proc-macro2", 846 + "quote", 847 + "syn", 848 + ] 849 + 850 + [[package]] 698 851 name = "thread_local" 699 852 version = "1.1.9" 700 853 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 704 857 ] 705 858 706 859 [[package]] 860 + name = "toml" 861 + version = "0.9.7" 862 + source = "registry+https://github.com/rust-lang/crates.io-index" 863 + checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" 864 + dependencies = [ 865 + "indexmap", 866 + "serde_core", 867 + "serde_spanned", 868 + "toml_datetime", 869 + "toml_parser", 870 + "toml_writer", 871 + "winnow", 872 + ] 873 + 874 + [[package]] 875 + name = "toml_datetime" 876 + version = "0.7.2" 877 + source = "registry+https://github.com/rust-lang/crates.io-index" 878 + checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" 879 + dependencies = [ 880 + "serde_core", 881 + ] 882 + 883 + [[package]] 884 + name = "toml_parser" 885 + version = "1.0.3" 886 + source = "registry+https://github.com/rust-lang/crates.io-index" 887 + checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" 888 + dependencies = [ 889 + "winnow", 890 + ] 891 + 892 + [[package]] 893 + name = "toml_writer" 894 + version = "1.0.3" 895 + source = "registry+https://github.com/rust-lang/crates.io-index" 896 + checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" 897 + 898 + [[package]] 707 899 name = "tracing" 708 900 version = "0.1.41" 709 901 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 761 953 ] 762 954 763 955 [[package]] 956 + name = "unicase" 957 + version = "2.8.1" 958 + source = "registry+https://github.com/rust-lang/crates.io-index" 959 + checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 960 + 961 + [[package]] 764 962 name = "unicode-ident" 765 963 version = "1.0.19" 766 964 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 806 1004 version = "0.1.1" 807 1005 source = "registry+https://github.com/rust-lang/crates.io-index" 808 1006 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 1007 + 1008 + [[package]] 1009 + name = "version_check" 1010 + version = "0.9.5" 1011 + source = "registry+https://github.com/rust-lang/crates.io-index" 1012 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 809 1013 810 1014 [[package]] 811 1015 name = "wasi" ··· 996 1200 version = "0.53.0" 997 1201 source = "registry+https://github.com/rust-lang/crates.io-index" 998 1202 checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" 1203 + 1204 + [[package]] 1205 + name = "winnow" 1206 + version = "0.7.13" 1207 + source = "registry+https://github.com/rust-lang/crates.io-index" 1208 + checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
+3 -3
ROADMAP.md
··· 18 18 19 19 | Task | Description | Key Crates | 20 20 | ---------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | 21 - | __Parser Core__ | Split files on `---` separators.<br>Detect title blocks, lists, and code fences.<br>Represent as `Vec<Slide>`. | [`pulldown-cmark`](https://docs.rs/pulldown-cmark/latest/pulldown_cmark/) | 22 - | __Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | 23 - | __Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | [`serde_yaml`](https://docs.rs/serde_yaml) | 21 + | __✓ Parser Core__ | Split files on `---` separators.<br>Detect title blocks, lists, and code fences.<br>Represent as `Vec<Slide>`. | [`pulldown-cmark`](https://docs.rs/pulldown-cmark/latest/pulldown_cmark/) | 22 + | __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | 23 + | __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | [`serde_yml`](https://docs.rs/serde_yml) | 24 24 | __Error & Validation__ | Provide friendly parser errors with file/line info. | [`thiserror`](https://docs.rs/thiserror) | 25 25 | __Basic CLI UX__ | `slides present file.md` runs full TUI.<br>`slides print` renders to stdout with width constraint. | `clap` | 26 26
+6
core/Cargo.toml
··· 7 7 tracing = "0.1.41" 8 8 owo-colors = "4.2.3" 9 9 crossterm = "0.29.0" 10 + pulldown-cmark = "0.13.0" 11 + serde = { version = "1.0.228", features = ["derive"] } 12 + serde_json = "1.0.145" 13 + serde_yml = "0.0.12" 14 + thiserror = "2.0.17" 15 + toml = "0.9.7"
+62
core/src/error.rs
··· 1 + use std::io; 2 + use thiserror::Error; 3 + 4 + /// Errors that can occur during slide parsing and rendering 5 + #[derive(Error, Debug)] 6 + pub enum SlideError { 7 + #[error("Failed to read file: {0}")] 8 + IoError(#[from] io::Error), 9 + 10 + #[error("Failed to parse markdown at line {line}: {message}")] 11 + ParseError { line: usize, message: String }, 12 + 13 + #[error("Invalid slide format: {0}")] 14 + InvalidFormat(String), 15 + 16 + #[error("Front matter error: {0}")] 17 + FrontMatterError(String), 18 + 19 + #[error("YAML parsing failed: {0}")] 20 + YamlError(#[from] serde_yml::Error), 21 + 22 + #[error("JSON parsing failed: {0}")] 23 + JsonError(#[from] serde_json::Error), 24 + } 25 + 26 + pub type Result<T> = std::result::Result<T, SlideError>; 27 + 28 + impl SlideError { 29 + pub fn parse_error(line: usize, message: impl Into<String>) -> Self { 30 + Self::ParseError { 31 + line, 32 + message: message.into(), 33 + } 34 + } 35 + 36 + pub fn invalid_format(message: impl Into<String>) -> Self { 37 + Self::InvalidFormat(message.into()) 38 + } 39 + 40 + pub fn front_matter(message: impl Into<String>) -> Self { 41 + Self::FrontMatterError(message.into()) 42 + } 43 + } 44 + 45 + #[cfg(test)] 46 + mod tests { 47 + use super::*; 48 + 49 + #[test] 50 + fn error_creation() { 51 + let err = SlideError::parse_error(10, "Invalid syntax"); 52 + assert!(err.to_string().contains("line 10")); 53 + assert!(err.to_string().contains("Invalid syntax")); 54 + } 55 + 56 + #[test] 57 + fn error_conversion() { 58 + let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found"); 59 + let slide_err: SlideError = io_err.into(); 60 + assert!(slide_err.to_string().contains("Failed to read file")); 61 + } 62 + }
+4
core/src/lib.rs
··· 1 + pub mod error; 2 + pub mod metadata; 3 + pub mod parser; 4 + pub mod slide; 1 5 pub mod term; 2 6 pub mod theme;
+240
core/src/metadata.rs
··· 1 + use crate::error::{Result, SlideError}; 2 + use serde::{Deserialize, Serialize}; 3 + use std::env; 4 + use std::time::SystemTime; 5 + 6 + /// Slide deck metadata from YAML frontmatter 7 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 8 + pub struct Meta { 9 + #[serde(default = "Meta::default_theme")] 10 + pub theme: String, 11 + #[serde(default = "Meta::default_author")] 12 + pub author: String, 13 + #[serde(default = "Meta::default_date")] 14 + pub date: String, 15 + #[serde(default = "Meta::default_paging")] 16 + pub paging: String, 17 + } 18 + 19 + impl Default for Meta { 20 + fn default() -> Self { 21 + Self { 22 + theme: Self::default_theme(), 23 + author: Self::default_author(), 24 + date: Self::default_date(), 25 + paging: Self::default_paging(), 26 + } 27 + } 28 + } 29 + 30 + impl Meta { 31 + pub fn new() -> Self { 32 + Self::default() 33 + } 34 + 35 + /// Parse metadata from YAML or TOML frontmatter header 36 + fn parse(header: &str, format: FrontmatterFormat) -> Result<Self> { 37 + if header.trim().is_empty() { 38 + return Ok(Self::default()); 39 + } 40 + 41 + match format { 42 + FrontmatterFormat::Yaml => match serde_yml::from_str(header) { 43 + Ok(meta) => Ok(meta), 44 + Err(e) => Err(SlideError::front_matter(format!("Failed to parse YAML: {}", e))), 45 + }, 46 + FrontmatterFormat::Toml => match toml::from_str(header) { 47 + Ok(meta) => Ok(meta), 48 + Err(e) => Err(SlideError::front_matter(format!("Failed to parse TOML: {}", e))), 49 + }, 50 + } 51 + } 52 + 53 + /// Extract frontmatter block with the given delimiter and format 54 + fn extract_frontmatter(rest: &str, delimiter: &str, format: FrontmatterFormat) -> Result<(Self, String)> { 55 + match rest.find(&format!("\n{}", delimiter)) { 56 + Some(end_pos) => Ok(( 57 + Self::parse(&rest[..end_pos], format)?, 58 + rest[end_pos + delimiter.len() + 1..].to_string(), 59 + )), 60 + None => Err(SlideError::front_matter(format!( 61 + "Unclosed {} frontmatter block (missing closing {})", 62 + format, delimiter 63 + ))), 64 + } 65 + } 66 + 67 + /// Extract metadata and content from markdown 68 + pub fn extract_from_markdown(markdown: &str) -> Result<(Self, String)> { 69 + let trimmed = markdown.trim_start(); 70 + match trimmed.chars().take(3).collect::<String>().as_str() { 71 + "---" => Self::extract_frontmatter(&trimmed[3..], "---", FrontmatterFormat::Yaml), 72 + "+++" => Self::extract_frontmatter(&trimmed[3..], "+++", FrontmatterFormat::Toml), 73 + _ => Ok((Self::default(), markdown.to_string())), 74 + } 75 + } 76 + 77 + /// Get theme from environment variable or return "default" 78 + fn default_theme() -> String { 79 + env::var("SLIDES_THEME").unwrap_or_else(|_| "default".to_string()) 80 + } 81 + 82 + /// Get current system user's name 83 + fn default_author() -> String { 84 + env::var("USER") 85 + .or_else(|_| env::var("USERNAME")) 86 + .unwrap_or_else(|_| "Unknown".to_string()) 87 + } 88 + 89 + /// Get current date in YYYY-MM-DD format 90 + fn default_date() -> String { 91 + match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { 92 + Ok(duration) => { 93 + let days = duration.as_secs() / 86400; 94 + let epoch_days = days as i64; 95 + let year = 1970 + (epoch_days / 365); 96 + 97 + let day_of_year = epoch_days % 365; 98 + let month = (day_of_year / 30) + 1; 99 + let day = (day_of_year % 30) + 1; 100 + format!("{:04}-{:02}-{:02}", year, month, day) 101 + } 102 + Err(_) => "Unknown".to_string(), 103 + } 104 + } 105 + 106 + /// Default paging format 107 + fn default_paging() -> String { 108 + "Slide %d / %d".to_string() 109 + } 110 + } 111 + 112 + /// Frontmatter format type 113 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 114 + enum FrontmatterFormat { 115 + Yaml, 116 + Toml, 117 + } 118 + 119 + impl std::fmt::Display for FrontmatterFormat { 120 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 121 + write!( 122 + f, 123 + "{}", 124 + match self { 125 + FrontmatterFormat::Yaml => "YAML", 126 + FrontmatterFormat::Toml => "TOML", 127 + } 128 + .to_string() 129 + ) 130 + } 131 + } 132 + 133 + #[cfg(test)] 134 + mod tests { 135 + use super::*; 136 + 137 + #[test] 138 + fn meta_default() { 139 + let meta = Meta::default(); 140 + assert_eq!(meta.paging, "Slide %d / %d"); 141 + assert!(!meta.theme.is_empty()); 142 + } 143 + 144 + #[test] 145 + fn meta_parse_yaml_empty() { 146 + let meta = Meta::parse("", FrontmatterFormat::Yaml).unwrap(); 147 + assert_eq!(meta, Meta::default()); 148 + } 149 + 150 + #[test] 151 + fn meta_parse_yaml_partial() { 152 + let yaml = "theme: dark\nauthor: Test Author"; 153 + let meta = Meta::parse(yaml, FrontmatterFormat::Yaml).unwrap(); 154 + assert_eq!(meta.theme, "dark"); 155 + assert_eq!(meta.author, "Test Author"); 156 + assert_eq!(meta.paging, "Slide %d / %d"); 157 + } 158 + 159 + #[test] 160 + fn meta_parse_yaml_full() { 161 + let yaml = r#" 162 + theme: monokai 163 + author: John Doe 164 + date: 2024-01-15 165 + paging: "Page %d of %d" 166 + "#; 167 + let meta = Meta::parse(yaml, FrontmatterFormat::Yaml).unwrap(); 168 + assert_eq!(meta.theme, "monokai"); 169 + assert_eq!(meta.author, "John Doe"); 170 + assert_eq!(meta.date, "2024-01-15"); 171 + assert_eq!(meta.paging, "Page %d of %d"); 172 + } 173 + 174 + #[test] 175 + fn meta_parse_toml() { 176 + let toml = r#" 177 + theme = "dracula" 178 + author = "Jane Doe" 179 + date = "2024-01-20" 180 + paging = "Slide %d of %d" 181 + "#; 182 + let meta = Meta::parse(toml, FrontmatterFormat::Toml).unwrap(); 183 + assert_eq!(meta.theme, "dracula"); 184 + assert_eq!(meta.author, "Jane Doe"); 185 + assert_eq!(meta.date, "2024-01-20"); 186 + assert_eq!(meta.paging, "Slide %d of %d"); 187 + } 188 + 189 + #[test] 190 + fn extract_frontmatter() { 191 + let markdown = r#"--- 192 + theme: dark 193 + author: Test 194 + --- 195 + # First Slide 196 + Content here"#; 197 + 198 + let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); 199 + assert_eq!(meta.theme, "dark"); 200 + assert_eq!(meta.author, "Test"); 201 + assert!(content.contains("# First Slide")); 202 + } 203 + 204 + #[test] 205 + fn extract_no_frontmatter() { 206 + let markdown = "# First Slide\nContent"; 207 + let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); 208 + assert_eq!(meta, Meta::default()); 209 + assert_eq!(content, markdown); 210 + } 211 + 212 + #[test] 213 + fn extract_unclosed_yaml_frontmatter() { 214 + let markdown = "---\ntheme: dark\n# Slide"; 215 + let result = Meta::extract_from_markdown(markdown); 216 + assert!(result.is_err()); 217 + } 218 + 219 + #[test] 220 + fn extract_toml_frontmatter() { 221 + let markdown = r#"+++ 222 + theme = "dark" 223 + author = "Test" 224 + +++ 225 + # First Slide 226 + Content here"#; 227 + 228 + let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); 229 + assert_eq!(meta.theme, "dark"); 230 + assert_eq!(meta.author, "Test"); 231 + assert!(content.contains("# First Slide")); 232 + } 233 + 234 + #[test] 235 + fn extract_unclosed_toml_frontmatter() { 236 + let markdown = "+++\ntheme = \"dark\"\n# Slide"; 237 + let result = Meta::extract_from_markdown(markdown); 238 + assert!(result.is_err()); 239 + } 240 + }
+398
core/src/parser.rs
··· 1 + use crate::error::Result; 2 + use crate::metadata::Meta; 3 + use crate::slide::*; 4 + use pulldown_cmark::{Event, Parser, Tag, TagEnd}; 5 + 6 + /// Parse markdown content into metadata and slides 7 + /// 8 + /// Extracts frontmatter metadata, then splits content on `---` separators. 9 + pub fn parse_slides_with_meta(markdown: &str) -> Result<(Meta, Vec<Slide>)> { 10 + let (meta, content) = Meta::extract_from_markdown(markdown)?; 11 + let slides = parse_slides(&content)?; 12 + Ok((meta, slides)) 13 + } 14 + 15 + /// Parse markdown content into a vector of slides 16 + pub fn parse_slides(markdown: &str) -> Result<Vec<Slide>> { 17 + let sections = split_slides(markdown); 18 + sections.into_iter().map(parse_slide).collect() 19 + } 20 + 21 + /// Split markdown content on `---` separators 22 + fn split_slides(markdown: &str) -> Vec<String> { 23 + let mut slides = Vec::new(); 24 + let mut current = String::new(); 25 + 26 + for line in markdown.lines() { 27 + let trimmed = line.trim(); 28 + if trimmed == "---" { 29 + if !current.trim().is_empty() { 30 + slides.push(current); 31 + current = String::new(); 32 + } 33 + } else { 34 + current.push_str(line); 35 + current.push('\n'); 36 + } 37 + } 38 + 39 + if !current.trim().is_empty() { 40 + slides.push(current); 41 + } 42 + 43 + slides 44 + } 45 + 46 + /// Parse a single slide from markdown 47 + fn parse_slide(markdown: String) -> Result<Slide> { 48 + let parser = Parser::new(&markdown); 49 + let mut blocks = Vec::new(); 50 + let mut block_stack: Vec<BlockBuilder> = Vec::new(); 51 + let mut current_style = TextStyle::default(); 52 + 53 + for event in parser { 54 + match event { 55 + Event::Start(tag) => match tag { 56 + Tag::Heading { level, .. } => { 57 + block_stack.push(BlockBuilder::Heading { 58 + level: level as u8, 59 + spans: Vec::new(), 60 + style: current_style.clone(), 61 + }); 62 + } 63 + Tag::Paragraph => { 64 + block_stack.push(BlockBuilder::Paragraph { 65 + spans: Vec::new(), 66 + style: current_style.clone(), 67 + }); 68 + } 69 + Tag::CodeBlock(kind) => { 70 + let language = match kind { 71 + pulldown_cmark::CodeBlockKind::Fenced(lang) => { 72 + if lang.is_empty() { 73 + None 74 + } else { 75 + Some(lang.to_string()) 76 + } 77 + } 78 + pulldown_cmark::CodeBlockKind::Indented => None, 79 + }; 80 + block_stack.push(BlockBuilder::Code { 81 + language, 82 + code: String::new(), 83 + }); 84 + } 85 + Tag::List(first) => { 86 + block_stack.push(BlockBuilder::List { 87 + ordered: first.is_some(), 88 + items: Vec::new(), 89 + current_item: Vec::new(), 90 + style: current_style.clone(), 91 + }); 92 + } 93 + Tag::BlockQuote(_) => { 94 + block_stack.push(BlockBuilder::BlockQuote { blocks: Vec::new() }); 95 + } 96 + Tag::Item => {} 97 + Tag::Emphasis => { 98 + current_style.italic = true; 99 + } 100 + Tag::Strong => { 101 + current_style.bold = true; 102 + } 103 + Tag::Strikethrough => { 104 + current_style.strikethrough = true; 105 + } 106 + _ => {} 107 + }, 108 + 109 + Event::End(tag_end) => match tag_end { 110 + TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => { 111 + if let Some(builder) = block_stack.pop() { 112 + blocks.push(builder.build()); 113 + } 114 + } 115 + TagEnd::List(_) => { 116 + if let Some(builder) = block_stack.pop() { 117 + blocks.push(builder.build()); 118 + } 119 + } 120 + TagEnd::BlockQuote(_) => { 121 + if let Some(builder) = block_stack.pop() { 122 + blocks.push(builder.build()); 123 + } 124 + } 125 + TagEnd::Item => { 126 + if let Some(BlockBuilder::List { 127 + current_item, items, .. 128 + }) = block_stack.last_mut() 129 + { 130 + if !current_item.is_empty() { 131 + items.push(ListItem { 132 + spans: current_item.drain(..).collect(), 133 + nested: None, 134 + }); 135 + } 136 + } 137 + } 138 + TagEnd::Emphasis => { 139 + current_style.italic = false; 140 + } 141 + TagEnd::Strong => { 142 + current_style.bold = false; 143 + } 144 + TagEnd::Strikethrough => { 145 + current_style.strikethrough = false; 146 + } 147 + _ => {} 148 + }, 149 + 150 + Event::Text(text) => { 151 + if let Some(builder) = block_stack.last_mut() { 152 + builder.add_text(text.to_string()); 153 + } 154 + } 155 + 156 + Event::Code(code) => { 157 + if let Some(builder) = block_stack.last_mut() { 158 + builder.add_code_span(code.to_string()); 159 + } 160 + } 161 + 162 + Event::SoftBreak | Event::HardBreak => { 163 + if let Some(builder) = block_stack.last_mut() { 164 + builder.add_text(" ".to_string()); 165 + } 166 + } 167 + 168 + Event::Rule => { 169 + blocks.push(Block::Rule); 170 + } 171 + 172 + _ => {} 173 + } 174 + } 175 + 176 + Ok(Slide::with_blocks(blocks)) 177 + } 178 + 179 + /// Helper to build blocks while parsing 180 + enum BlockBuilder { 181 + Heading { 182 + level: u8, 183 + spans: Vec<TextSpan>, 184 + style: TextStyle, 185 + }, 186 + Paragraph { 187 + spans: Vec<TextSpan>, 188 + style: TextStyle, 189 + }, 190 + Code { 191 + language: Option<String>, 192 + code: String, 193 + }, 194 + List { 195 + ordered: bool, 196 + items: Vec<ListItem>, 197 + current_item: Vec<TextSpan>, 198 + style: TextStyle, 199 + }, 200 + BlockQuote { 201 + blocks: Vec<Block>, 202 + }, 203 + } 204 + 205 + impl BlockBuilder { 206 + fn add_text(&mut self, text: String) { 207 + match self { 208 + Self::Heading { spans, style, .. } | Self::Paragraph { spans, style } => { 209 + if !text.is_empty() { 210 + spans.push(TextSpan { 211 + text, 212 + style: style.clone(), 213 + }); 214 + } 215 + } 216 + Self::Code { code, .. } => { 217 + code.push_str(&text); 218 + } 219 + Self::List { 220 + current_item, style, .. 221 + } => { 222 + if !text.is_empty() { 223 + current_item.push(TextSpan { 224 + text, 225 + style: style.clone(), 226 + }); 227 + } 228 + } 229 + _ => {} 230 + } 231 + } 232 + 233 + fn add_code_span(&mut self, code: String) { 234 + match self { 235 + Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { 236 + spans.push(TextSpan { 237 + text: code, 238 + style: TextStyle { 239 + code: true, 240 + ..Default::default() 241 + }, 242 + }); 243 + } 244 + Self::List { current_item, .. } => { 245 + current_item.push(TextSpan { 246 + text: code, 247 + style: TextStyle { 248 + code: true, 249 + ..Default::default() 250 + }, 251 + }); 252 + } 253 + _ => {} 254 + } 255 + } 256 + 257 + fn build(self) -> Block { 258 + match self { 259 + Self::Heading { level, spans, .. } => Block::Heading { level, spans }, 260 + Self::Paragraph { spans, .. } => Block::Paragraph { spans }, 261 + Self::Code { language, code } => Block::Code(CodeBlock { language, code }), 262 + Self::List { ordered, items, .. } => Block::List(List { ordered, items }), 263 + Self::BlockQuote { blocks } => Block::BlockQuote { blocks }, 264 + } 265 + } 266 + } 267 + 268 + #[cfg(test)] 269 + mod tests { 270 + use super::*; 271 + 272 + #[test] 273 + fn split_slides_basic() { 274 + let markdown = "# Slide 1\n---\n# Slide 2"; 275 + let slides = split_slides(markdown); 276 + assert_eq!(slides.len(), 2); 277 + assert!(slides[0].contains("Slide 1")); 278 + assert!(slides[1].contains("Slide 2")); 279 + } 280 + 281 + #[test] 282 + fn split_slides_empty() { 283 + let markdown = ""; 284 + let slides = split_slides(markdown); 285 + assert_eq!(slides.len(), 0); 286 + } 287 + 288 + #[test] 289 + fn split_slides_single() { 290 + let markdown = "# Only Slide"; 291 + let slides = split_slides(markdown); 292 + assert_eq!(slides.len(), 1); 293 + } 294 + 295 + #[test] 296 + fn parse_heading() { 297 + let slides = parse_slides("# Hello World").unwrap(); 298 + assert_eq!(slides.len(), 1); 299 + 300 + match &slides[0].blocks[0] { 301 + Block::Heading { level, spans } => { 302 + assert_eq!(*level, 1); 303 + assert_eq!(spans[0].text, "Hello World"); 304 + } 305 + _ => panic!("Expected heading"), 306 + } 307 + } 308 + 309 + #[test] 310 + fn parse_paragraph() { 311 + let slides = parse_slides("This is a paragraph").unwrap(); 312 + assert_eq!(slides.len(), 1); 313 + 314 + match &slides[0].blocks[0] { 315 + Block::Paragraph { spans } => { 316 + assert_eq!(spans[0].text, "This is a paragraph"); 317 + } 318 + _ => panic!("Expected paragraph"), 319 + } 320 + } 321 + 322 + #[test] 323 + fn parse_code_block() { 324 + let markdown = "```rust\nfn main() {}\n```"; 325 + let slides = parse_slides(markdown).unwrap(); 326 + 327 + match &slides[0].blocks[0] { 328 + Block::Code(code) => { 329 + assert_eq!(code.language, Some("rust".to_string())); 330 + assert!(code.code.contains("fn main()")); 331 + } 332 + _ => panic!("Expected code block"), 333 + } 334 + } 335 + 336 + #[test] 337 + fn parse_list() { 338 + let markdown = "- Item 1\n- Item 2"; 339 + let slides = parse_slides(markdown).unwrap(); 340 + 341 + match &slides[0].blocks[0] { 342 + Block::List(list) => { 343 + assert!(!list.ordered); 344 + assert_eq!(list.items.len(), 2); 345 + assert_eq!(list.items[0].spans[0].text, "Item 1"); 346 + } 347 + _ => panic!("Expected list"), 348 + } 349 + } 350 + 351 + #[test] 352 + fn parse_multiple_slides() { 353 + let markdown = "# Slide 1\nContent 1\n---\n# Slide 2\nContent 2"; 354 + let slides = parse_slides(markdown).unwrap(); 355 + assert_eq!(slides.len(), 2); 356 + } 357 + 358 + #[test] 359 + fn parse_with_yaml_metadata() { 360 + let markdown = r#"--- 361 + theme: dark 362 + author: Test Author 363 + --- 364 + # First Slide 365 + Content here 366 + --- 367 + # Second Slide 368 + More content"#; 369 + 370 + let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 371 + assert_eq!(meta.theme, "dark"); 372 + assert_eq!(meta.author, "Test Author"); 373 + assert_eq!(slides.len(), 2); 374 + } 375 + 376 + #[test] 377 + fn parse_with_toml_metadata() { 378 + let markdown = r#"+++ 379 + theme = "monokai" 380 + author = "Jane Doe" 381 + +++ 382 + # Slide One 383 + Test content"#; 384 + 385 + let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 386 + assert_eq!(meta.theme, "monokai"); 387 + assert_eq!(meta.author, "Jane Doe"); 388 + assert_eq!(slides.len(), 1); 389 + } 390 + 391 + #[test] 392 + fn parse_without_metadata() { 393 + let markdown = "# Slide\nContent"; 394 + let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); 395 + assert_eq!(meta, Meta::default()); 396 + assert_eq!(slides.len(), 1); 397 + } 398 + }
+209
core/src/slide.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + /// A single slide in a presentation 4 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 5 + pub struct Slide { 6 + /// The content blocks that make up this slide 7 + pub blocks: Vec<Block>, 8 + /// Optional speaker notes (not displayed on main slide) 9 + pub notes: Option<String>, 10 + } 11 + 12 + impl Slide { 13 + pub fn new() -> Self { 14 + Self { 15 + blocks: Vec::new(), 16 + notes: None, 17 + } 18 + } 19 + 20 + pub fn with_blocks(blocks: Vec<Block>) -> Self { 21 + Self { blocks, notes: None } 22 + } 23 + 24 + pub fn is_empty(&self) -> bool { 25 + self.blocks.is_empty() 26 + } 27 + } 28 + 29 + impl Default for Slide { 30 + fn default() -> Self { 31 + Self::new() 32 + } 33 + } 34 + 35 + /// Content block types that can appear in a slide 36 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 37 + pub enum Block { 38 + /// Heading with level (1-6) and text spans 39 + Heading { level: u8, spans: Vec<TextSpan> }, 40 + /// Paragraph of text spans 41 + Paragraph { spans: Vec<TextSpan> }, 42 + /// Code block with optional language and content 43 + Code(CodeBlock), 44 + /// Ordered or unordered list 45 + List(List), 46 + /// Horizontal rule/divider 47 + Rule, 48 + /// Block quote 49 + BlockQuote { blocks: Vec<Block> }, 50 + /// Table 51 + Table(Table), 52 + } 53 + 54 + /// Styled text span within a block 55 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 56 + pub struct TextSpan { 57 + pub text: String, 58 + pub style: TextStyle, 59 + } 60 + 61 + impl TextSpan { 62 + pub fn plain(text: impl Into<String>) -> Self { 63 + Self { 64 + text: text.into(), 65 + style: TextStyle::default(), 66 + } 67 + } 68 + 69 + pub fn bold(text: impl Into<String>) -> Self { 70 + Self { 71 + text: text.into(), 72 + style: TextStyle { 73 + bold: true, 74 + ..Default::default() 75 + }, 76 + } 77 + } 78 + 79 + pub fn italic(text: impl Into<String>) -> Self { 80 + Self { 81 + text: text.into(), 82 + style: TextStyle { 83 + italic: true, 84 + ..Default::default() 85 + }, 86 + } 87 + } 88 + 89 + pub fn code(text: impl Into<String>) -> Self { 90 + Self { 91 + text: text.into(), 92 + style: TextStyle { 93 + code: true, 94 + ..Default::default() 95 + }, 96 + } 97 + } 98 + } 99 + 100 + /// Text styling flags 101 + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] 102 + pub struct TextStyle { 103 + pub bold: bool, 104 + pub italic: bool, 105 + pub strikethrough: bool, 106 + pub code: bool, 107 + } 108 + 109 + /// Code block with language and content 110 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 111 + pub struct CodeBlock { 112 + /// Programming language for syntax highlighting 113 + pub language: Option<String>, 114 + /// Raw code content 115 + pub code: String, 116 + } 117 + 118 + impl CodeBlock { 119 + pub fn new(code: impl Into<String>) -> Self { 120 + Self { 121 + language: None, 122 + code: code.into(), 123 + } 124 + } 125 + 126 + pub fn with_language(language: impl Into<String>, code: impl Into<String>) -> Self { 127 + Self { 128 + language: Some(language.into()), 129 + code: code.into(), 130 + } 131 + } 132 + } 133 + 134 + /// List (ordered or unordered) 135 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 136 + pub struct List { 137 + pub ordered: bool, 138 + pub items: Vec<ListItem>, 139 + } 140 + 141 + /// Single list item that can contain blocks 142 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 143 + pub struct ListItem { 144 + pub spans: Vec<TextSpan>, 145 + pub nested: Option<Box<List>>, 146 + } 147 + 148 + /// Table with headers and rows 149 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 150 + pub struct Table { 151 + pub headers: Vec<Vec<TextSpan>>, 152 + pub rows: Vec<Vec<Vec<TextSpan>>>, 153 + pub alignments: Vec<Alignment>, 154 + } 155 + 156 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 157 + pub enum Alignment { 158 + Left, 159 + Center, 160 + Right, 161 + } 162 + 163 + #[cfg(test)] 164 + mod tests { 165 + use super::*; 166 + 167 + #[test] 168 + fn slide_creation() { 169 + let slide = Slide::new(); 170 + assert!(slide.is_empty()); 171 + assert_eq!(slide.blocks.len(), 0); 172 + } 173 + 174 + #[test] 175 + fn slide_with_blocks() { 176 + let blocks = vec![Block::Paragraph { 177 + spans: vec![TextSpan::plain("Hello")], 178 + }]; 179 + let slide = Slide::with_blocks(blocks.clone()); 180 + assert!(!slide.is_empty()); 181 + assert_eq!(slide.blocks.len(), 1); 182 + } 183 + 184 + #[test] 185 + fn text_span_styles() { 186 + let plain = TextSpan::plain("text"); 187 + assert!(!plain.style.bold); 188 + assert!(!plain.style.italic); 189 + 190 + let bold = TextSpan::bold("text"); 191 + assert!(bold.style.bold); 192 + 193 + let italic = TextSpan::italic("text"); 194 + assert!(italic.style.italic); 195 + 196 + let code = TextSpan::code("text"); 197 + assert!(code.style.code); 198 + } 199 + 200 + #[test] 201 + fn code_block_creation() { 202 + let code = CodeBlock::new("fn main() {}"); 203 + assert_eq!(code.language, None); 204 + assert_eq!(code.code, "fn main() {}"); 205 + 206 + let rust_code = CodeBlock::with_language("rust", "fn main() {}"); 207 + assert_eq!(rust_code.language, Some("rust".to_string())); 208 + } 209 + }
+26 -4
core/src/term.rs
··· 16 16 17 17 impl Default for Terminal { 18 18 fn default() -> Self { 19 - Self { 20 - in_alternate_screen: true, 21 - in_raw_mode: true, 22 - } 19 + Self { in_alternate_screen: true, in_raw_mode: true } 23 20 } 24 21 } 25 22 ··· 175 172 fn input_event_resize() { 176 173 let resize = InputEvent::from_crossterm(Event::Resize(80, 24)); 177 174 assert_eq!(resize, InputEvent::Resize { width: 80, height: 24 }); 175 + } 176 + 177 + #[test] 178 + fn terminal_default_state() { 179 + let terminal = Terminal::default(); 180 + assert!(terminal.in_alternate_screen); 181 + assert!(terminal.in_raw_mode); 182 + } 183 + 184 + #[test] 185 + fn terminal_restore_idempotent() { 186 + let mut terminal = Terminal { in_alternate_screen: false, in_raw_mode: false }; 187 + 188 + assert!(terminal.restore().is_ok()); 189 + assert!(terminal.restore().is_ok()); 190 + assert!(!terminal.in_alternate_screen); 191 + assert!(!terminal.in_raw_mode); 192 + } 193 + 194 + #[test] 195 + fn terminal_restore_clears_flags() { 196 + let mut terminal = Terminal { in_alternate_screen: false, in_raw_mode: false }; 197 + let _ = terminal.restore(); 198 + assert!(!terminal.in_alternate_screen); 199 + assert!(!terminal.in_raw_mode); 178 200 } 179 201 }