diff --git a/Cargo.lock b/Cargo.lock index 89ac404..825111c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "bitflags" version = "2.9.4" @@ -296,6 +302,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.0", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -319,6 +334,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indoc" version = "2.0.6" @@ -371,6 +396,16 @@ version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -413,6 +448,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "mio" version = "1.0.4" @@ -496,6 +537,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quote" version = "1.0.41" @@ -579,6 +639,73 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -636,6 +763,12 @@ version = "0.1.0" dependencies = [ "crossterm 0.29.0", "owo-colors", + "pulldown-cmark", + "serde", + "serde_json", + "serde_yml", + "thiserror", + "toml", "tracing", ] @@ -694,6 +827,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -703,6 +856,45 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "toml" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" + [[package]] name = "tracing" version = "0.1.41" @@ -760,6 +952,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -807,6 +1005,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -996,3 +1200,9 @@ name = "windows_x86_64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" diff --git a/ROADMAP.md b/ROADMAP.md index 33db2a9..0056aa7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,9 +18,9 @@ __Objective:__ Parse markdown documents into a rich `Slide` struct. | Task | Description | Key Crates | | ---------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -| __Parser Core__ | Split files on `---` separators.
Detect title blocks, lists, and code fences.
Represent as `Vec`. | [`pulldown-cmark`](https://docs.rs/pulldown-cmark/latest/pulldown_cmark/) | -| __Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | -| __Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | [`serde_yaml`](https://docs.rs/serde_yaml) | +| __✓ Parser Core__ | Split files on `---` separators.
Detect title blocks, lists, and code fences.
Represent as `Vec`. | [`pulldown-cmark`](https://docs.rs/pulldown-cmark/latest/pulldown_cmark/) | +| __✓ Slide Model__ | Define structs: `Slide`, `Block`, `TextSpan`, `CodeBlock`, etc. | Internal | +| __✓ Metadata Parsing__ | Optional front matter (YAML/TOML) for theme, author, etc. | [`serde_yml`](https://docs.rs/serde_yml) | | __Error & Validation__ | Provide friendly parser errors with file/line info. | [`thiserror`](https://docs.rs/thiserror) | | __Basic CLI UX__ | `slides present file.md` runs full TUI.
`slides print` renders to stdout with width constraint. | `clap` | diff --git a/core/Cargo.toml b/core/Cargo.toml index e3d5dc2..cd4cb3f 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -7,3 +7,9 @@ edition = "2024" tracing = "0.1.41" owo-colors = "4.2.3" crossterm = "0.29.0" +pulldown-cmark = "0.13.0" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +serde_yml = "0.0.12" +thiserror = "2.0.17" +toml = "0.9.7" diff --git a/core/src/error.rs b/core/src/error.rs new file mode 100644 index 0000000..58fc46b --- /dev/null +++ b/core/src/error.rs @@ -0,0 +1,62 @@ +use std::io; +use thiserror::Error; + +/// Errors that can occur during slide parsing and rendering +#[derive(Error, Debug)] +pub enum SlideError { + #[error("Failed to read file: {0}")] + IoError(#[from] io::Error), + + #[error("Failed to parse markdown at line {line}: {message}")] + ParseError { line: usize, message: String }, + + #[error("Invalid slide format: {0}")] + InvalidFormat(String), + + #[error("Front matter error: {0}")] + FrontMatterError(String), + + #[error("YAML parsing failed: {0}")] + YamlError(#[from] serde_yml::Error), + + #[error("JSON parsing failed: {0}")] + JsonError(#[from] serde_json::Error), +} + +pub type Result = std::result::Result; + +impl SlideError { + pub fn parse_error(line: usize, message: impl Into) -> Self { + Self::ParseError { + line, + message: message.into(), + } + } + + pub fn invalid_format(message: impl Into) -> Self { + Self::InvalidFormat(message.into()) + } + + pub fn front_matter(message: impl Into) -> Self { + Self::FrontMatterError(message.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_creation() { + let err = SlideError::parse_error(10, "Invalid syntax"); + assert!(err.to_string().contains("line 10")); + assert!(err.to_string().contains("Invalid syntax")); + } + + #[test] + fn error_conversion() { + let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found"); + let slide_err: SlideError = io_err.into(); + assert!(slide_err.to_string().contains("Failed to read file")); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index f78f421..5f51b68 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -1,2 +1,6 @@ +pub mod error; +pub mod metadata; +pub mod parser; +pub mod slide; pub mod term; pub mod theme; diff --git a/core/src/metadata.rs b/core/src/metadata.rs new file mode 100644 index 0000000..540ada0 --- /dev/null +++ b/core/src/metadata.rs @@ -0,0 +1,240 @@ +use crate::error::{Result, SlideError}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::time::SystemTime; + +/// Slide deck metadata from YAML frontmatter +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Meta { + #[serde(default = "Meta::default_theme")] + pub theme: String, + #[serde(default = "Meta::default_author")] + pub author: String, + #[serde(default = "Meta::default_date")] + pub date: String, + #[serde(default = "Meta::default_paging")] + pub paging: String, +} + +impl Default for Meta { + fn default() -> Self { + Self { + theme: Self::default_theme(), + author: Self::default_author(), + date: Self::default_date(), + paging: Self::default_paging(), + } + } +} + +impl Meta { + pub fn new() -> Self { + Self::default() + } + + /// Parse metadata from YAML or TOML frontmatter header + fn parse(header: &str, format: FrontmatterFormat) -> Result { + if header.trim().is_empty() { + return Ok(Self::default()); + } + + match format { + FrontmatterFormat::Yaml => match serde_yml::from_str(header) { + Ok(meta) => Ok(meta), + Err(e) => Err(SlideError::front_matter(format!("Failed to parse YAML: {}", e))), + }, + FrontmatterFormat::Toml => match toml::from_str(header) { + Ok(meta) => Ok(meta), + Err(e) => Err(SlideError::front_matter(format!("Failed to parse TOML: {}", e))), + }, + } + } + + /// Extract frontmatter block with the given delimiter and format + fn extract_frontmatter(rest: &str, delimiter: &str, format: FrontmatterFormat) -> Result<(Self, String)> { + match rest.find(&format!("\n{}", delimiter)) { + Some(end_pos) => Ok(( + Self::parse(&rest[..end_pos], format)?, + rest[end_pos + delimiter.len() + 1..].to_string(), + )), + None => Err(SlideError::front_matter(format!( + "Unclosed {} frontmatter block (missing closing {})", + format, delimiter + ))), + } + } + + /// Extract metadata and content from markdown + pub fn extract_from_markdown(markdown: &str) -> Result<(Self, String)> { + let trimmed = markdown.trim_start(); + match trimmed.chars().take(3).collect::().as_str() { + "---" => Self::extract_frontmatter(&trimmed[3..], "---", FrontmatterFormat::Yaml), + "+++" => Self::extract_frontmatter(&trimmed[3..], "+++", FrontmatterFormat::Toml), + _ => Ok((Self::default(), markdown.to_string())), + } + } + + /// Get theme from environment variable or return "default" + fn default_theme() -> String { + env::var("SLIDES_THEME").unwrap_or_else(|_| "default".to_string()) + } + + /// Get current system user's name + fn default_author() -> String { + env::var("USER") + .or_else(|_| env::var("USERNAME")) + .unwrap_or_else(|_| "Unknown".to_string()) + } + + /// Get current date in YYYY-MM-DD format + fn default_date() -> String { + match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + Ok(duration) => { + let days = duration.as_secs() / 86400; + let epoch_days = days as i64; + let year = 1970 + (epoch_days / 365); + + let day_of_year = epoch_days % 365; + let month = (day_of_year / 30) + 1; + let day = (day_of_year % 30) + 1; + format!("{:04}-{:02}-{:02}", year, month, day) + } + Err(_) => "Unknown".to_string(), + } + } + + /// Default paging format + fn default_paging() -> String { + "Slide %d / %d".to_string() + } +} + +/// Frontmatter format type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FrontmatterFormat { + Yaml, + Toml, +} + +impl std::fmt::Display for FrontmatterFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + FrontmatterFormat::Yaml => "YAML", + FrontmatterFormat::Toml => "TOML", + } + .to_string() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn meta_default() { + let meta = Meta::default(); + assert_eq!(meta.paging, "Slide %d / %d"); + assert!(!meta.theme.is_empty()); + } + + #[test] + fn meta_parse_yaml_empty() { + let meta = Meta::parse("", FrontmatterFormat::Yaml).unwrap(); + assert_eq!(meta, Meta::default()); + } + + #[test] + fn meta_parse_yaml_partial() { + let yaml = "theme: dark\nauthor: Test Author"; + let meta = Meta::parse(yaml, FrontmatterFormat::Yaml).unwrap(); + assert_eq!(meta.theme, "dark"); + assert_eq!(meta.author, "Test Author"); + assert_eq!(meta.paging, "Slide %d / %d"); + } + + #[test] + fn meta_parse_yaml_full() { + let yaml = r#" +theme: monokai +author: John Doe +date: 2024-01-15 +paging: "Page %d of %d" + "#; + let meta = Meta::parse(yaml, FrontmatterFormat::Yaml).unwrap(); + assert_eq!(meta.theme, "monokai"); + assert_eq!(meta.author, "John Doe"); + assert_eq!(meta.date, "2024-01-15"); + assert_eq!(meta.paging, "Page %d of %d"); + } + + #[test] + fn meta_parse_toml() { + let toml = r#" +theme = "dracula" +author = "Jane Doe" +date = "2024-01-20" +paging = "Slide %d of %d" + "#; + let meta = Meta::parse(toml, FrontmatterFormat::Toml).unwrap(); + assert_eq!(meta.theme, "dracula"); + assert_eq!(meta.author, "Jane Doe"); + assert_eq!(meta.date, "2024-01-20"); + assert_eq!(meta.paging, "Slide %d of %d"); + } + + #[test] + fn extract_frontmatter() { + let markdown = r#"--- +theme: dark +author: Test +--- +# First Slide +Content here"#; + + let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); + assert_eq!(meta.theme, "dark"); + assert_eq!(meta.author, "Test"); + assert!(content.contains("# First Slide")); + } + + #[test] + fn extract_no_frontmatter() { + let markdown = "# First Slide\nContent"; + let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); + assert_eq!(meta, Meta::default()); + assert_eq!(content, markdown); + } + + #[test] + fn extract_unclosed_yaml_frontmatter() { + let markdown = "---\ntheme: dark\n# Slide"; + let result = Meta::extract_from_markdown(markdown); + assert!(result.is_err()); + } + + #[test] + fn extract_toml_frontmatter() { + let markdown = r#"+++ +theme = "dark" +author = "Test" ++++ +# First Slide +Content here"#; + + let (meta, content) = Meta::extract_from_markdown(markdown).unwrap(); + assert_eq!(meta.theme, "dark"); + assert_eq!(meta.author, "Test"); + assert!(content.contains("# First Slide")); + } + + #[test] + fn extract_unclosed_toml_frontmatter() { + let markdown = "+++\ntheme = \"dark\"\n# Slide"; + let result = Meta::extract_from_markdown(markdown); + assert!(result.is_err()); + } +} diff --git a/core/src/parser.rs b/core/src/parser.rs new file mode 100644 index 0000000..2c422e4 --- /dev/null +++ b/core/src/parser.rs @@ -0,0 +1,398 @@ +use crate::error::Result; +use crate::metadata::Meta; +use crate::slide::*; +use pulldown_cmark::{Event, Parser, Tag, TagEnd}; + +/// Parse markdown content into metadata and slides +/// +/// Extracts frontmatter metadata, then splits content on `---` separators. +pub fn parse_slides_with_meta(markdown: &str) -> Result<(Meta, Vec)> { + let (meta, content) = Meta::extract_from_markdown(markdown)?; + let slides = parse_slides(&content)?; + Ok((meta, slides)) +} + +/// Parse markdown content into a vector of slides +pub fn parse_slides(markdown: &str) -> Result> { + let sections = split_slides(markdown); + sections.into_iter().map(parse_slide).collect() +} + +/// Split markdown content on `---` separators +fn split_slides(markdown: &str) -> Vec { + let mut slides = Vec::new(); + let mut current = String::new(); + + for line in markdown.lines() { + let trimmed = line.trim(); + if trimmed == "---" { + if !current.trim().is_empty() { + slides.push(current); + current = String::new(); + } + } else { + current.push_str(line); + current.push('\n'); + } + } + + if !current.trim().is_empty() { + slides.push(current); + } + + slides +} + +/// Parse a single slide from markdown +fn parse_slide(markdown: String) -> Result { + let parser = Parser::new(&markdown); + let mut blocks = Vec::new(); + let mut block_stack: Vec = Vec::new(); + let mut current_style = TextStyle::default(); + + for event in parser { + match event { + Event::Start(tag) => match tag { + Tag::Heading { level, .. } => { + block_stack.push(BlockBuilder::Heading { + level: level as u8, + spans: Vec::new(), + style: current_style.clone(), + }); + } + Tag::Paragraph => { + block_stack.push(BlockBuilder::Paragraph { + spans: Vec::new(), + style: current_style.clone(), + }); + } + Tag::CodeBlock(kind) => { + let language = match kind { + pulldown_cmark::CodeBlockKind::Fenced(lang) => { + if lang.is_empty() { + None + } else { + Some(lang.to_string()) + } + } + pulldown_cmark::CodeBlockKind::Indented => None, + }; + block_stack.push(BlockBuilder::Code { + language, + code: String::new(), + }); + } + Tag::List(first) => { + block_stack.push(BlockBuilder::List { + ordered: first.is_some(), + items: Vec::new(), + current_item: Vec::new(), + style: current_style.clone(), + }); + } + Tag::BlockQuote(_) => { + block_stack.push(BlockBuilder::BlockQuote { blocks: Vec::new() }); + } + Tag::Item => {} + Tag::Emphasis => { + current_style.italic = true; + } + Tag::Strong => { + current_style.bold = true; + } + Tag::Strikethrough => { + current_style.strikethrough = true; + } + _ => {} + }, + + Event::End(tag_end) => match tag_end { + TagEnd::Heading(_) | TagEnd::Paragraph | TagEnd::CodeBlock => { + if let Some(builder) = block_stack.pop() { + blocks.push(builder.build()); + } + } + TagEnd::List(_) => { + if let Some(builder) = block_stack.pop() { + blocks.push(builder.build()); + } + } + TagEnd::BlockQuote(_) => { + if let Some(builder) = block_stack.pop() { + blocks.push(builder.build()); + } + } + TagEnd::Item => { + if let Some(BlockBuilder::List { + current_item, items, .. + }) = block_stack.last_mut() + { + if !current_item.is_empty() { + items.push(ListItem { + spans: current_item.drain(..).collect(), + nested: None, + }); + } + } + } + TagEnd::Emphasis => { + current_style.italic = false; + } + TagEnd::Strong => { + current_style.bold = false; + } + TagEnd::Strikethrough => { + current_style.strikethrough = false; + } + _ => {} + }, + + Event::Text(text) => { + if let Some(builder) = block_stack.last_mut() { + builder.add_text(text.to_string()); + } + } + + Event::Code(code) => { + if let Some(builder) = block_stack.last_mut() { + builder.add_code_span(code.to_string()); + } + } + + Event::SoftBreak | Event::HardBreak => { + if let Some(builder) = block_stack.last_mut() { + builder.add_text(" ".to_string()); + } + } + + Event::Rule => { + blocks.push(Block::Rule); + } + + _ => {} + } + } + + Ok(Slide::with_blocks(blocks)) +} + +/// Helper to build blocks while parsing +enum BlockBuilder { + Heading { + level: u8, + spans: Vec, + style: TextStyle, + }, + Paragraph { + spans: Vec, + style: TextStyle, + }, + Code { + language: Option, + code: String, + }, + List { + ordered: bool, + items: Vec, + current_item: Vec, + style: TextStyle, + }, + BlockQuote { + blocks: Vec, + }, +} + +impl BlockBuilder { + fn add_text(&mut self, text: String) { + match self { + Self::Heading { spans, style, .. } | Self::Paragraph { spans, style } => { + if !text.is_empty() { + spans.push(TextSpan { + text, + style: style.clone(), + }); + } + } + Self::Code { code, .. } => { + code.push_str(&text); + } + Self::List { + current_item, style, .. + } => { + if !text.is_empty() { + current_item.push(TextSpan { + text, + style: style.clone(), + }); + } + } + _ => {} + } + } + + fn add_code_span(&mut self, code: String) { + match self { + Self::Heading { spans, .. } | Self::Paragraph { spans, .. } => { + spans.push(TextSpan { + text: code, + style: TextStyle { + code: true, + ..Default::default() + }, + }); + } + Self::List { current_item, .. } => { + current_item.push(TextSpan { + text: code, + style: TextStyle { + code: true, + ..Default::default() + }, + }); + } + _ => {} + } + } + + fn build(self) -> Block { + match self { + Self::Heading { level, spans, .. } => Block::Heading { level, spans }, + Self::Paragraph { spans, .. } => Block::Paragraph { spans }, + Self::Code { language, code } => Block::Code(CodeBlock { language, code }), + Self::List { ordered, items, .. } => Block::List(List { ordered, items }), + Self::BlockQuote { blocks } => Block::BlockQuote { blocks }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_slides_basic() { + let markdown = "# Slide 1\n---\n# Slide 2"; + let slides = split_slides(markdown); + assert_eq!(slides.len(), 2); + assert!(slides[0].contains("Slide 1")); + assert!(slides[1].contains("Slide 2")); + } + + #[test] + fn split_slides_empty() { + let markdown = ""; + let slides = split_slides(markdown); + assert_eq!(slides.len(), 0); + } + + #[test] + fn split_slides_single() { + let markdown = "# Only Slide"; + let slides = split_slides(markdown); + assert_eq!(slides.len(), 1); + } + + #[test] + fn parse_heading() { + let slides = parse_slides("# Hello World").unwrap(); + assert_eq!(slides.len(), 1); + + match &slides[0].blocks[0] { + Block::Heading { level, spans } => { + assert_eq!(*level, 1); + assert_eq!(spans[0].text, "Hello World"); + } + _ => panic!("Expected heading"), + } + } + + #[test] + fn parse_paragraph() { + let slides = parse_slides("This is a paragraph").unwrap(); + assert_eq!(slides.len(), 1); + + match &slides[0].blocks[0] { + Block::Paragraph { spans } => { + assert_eq!(spans[0].text, "This is a paragraph"); + } + _ => panic!("Expected paragraph"), + } + } + + #[test] + fn parse_code_block() { + let markdown = "```rust\nfn main() {}\n```"; + let slides = parse_slides(markdown).unwrap(); + + match &slides[0].blocks[0] { + Block::Code(code) => { + assert_eq!(code.language, Some("rust".to_string())); + assert!(code.code.contains("fn main()")); + } + _ => panic!("Expected code block"), + } + } + + #[test] + fn parse_list() { + let markdown = "- Item 1\n- Item 2"; + let slides = parse_slides(markdown).unwrap(); + + match &slides[0].blocks[0] { + Block::List(list) => { + assert!(!list.ordered); + assert_eq!(list.items.len(), 2); + assert_eq!(list.items[0].spans[0].text, "Item 1"); + } + _ => panic!("Expected list"), + } + } + + #[test] + fn parse_multiple_slides() { + let markdown = "# Slide 1\nContent 1\n---\n# Slide 2\nContent 2"; + let slides = parse_slides(markdown).unwrap(); + assert_eq!(slides.len(), 2); + } + + #[test] + fn parse_with_yaml_metadata() { + let markdown = r#"--- +theme: dark +author: Test Author +--- +# First Slide +Content here +--- +# Second Slide +More content"#; + + let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); + assert_eq!(meta.theme, "dark"); + assert_eq!(meta.author, "Test Author"); + assert_eq!(slides.len(), 2); + } + + #[test] + fn parse_with_toml_metadata() { + let markdown = r#"+++ +theme = "monokai" +author = "Jane Doe" ++++ +# Slide One +Test content"#; + + let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); + assert_eq!(meta.theme, "monokai"); + assert_eq!(meta.author, "Jane Doe"); + assert_eq!(slides.len(), 1); + } + + #[test] + fn parse_without_metadata() { + let markdown = "# Slide\nContent"; + let (meta, slides) = parse_slides_with_meta(markdown).unwrap(); + assert_eq!(meta, Meta::default()); + assert_eq!(slides.len(), 1); + } +} diff --git a/core/src/slide.rs b/core/src/slide.rs new file mode 100644 index 0000000..e4a7628 --- /dev/null +++ b/core/src/slide.rs @@ -0,0 +1,209 @@ +use serde::{Deserialize, Serialize}; + +/// A single slide in a presentation +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Slide { + /// The content blocks that make up this slide + pub blocks: Vec, + /// Optional speaker notes (not displayed on main slide) + pub notes: Option, +} + +impl Slide { + pub fn new() -> Self { + Self { + blocks: Vec::new(), + notes: None, + } + } + + pub fn with_blocks(blocks: Vec) -> Self { + Self { blocks, notes: None } + } + + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} + +impl Default for Slide { + fn default() -> Self { + Self::new() + } +} + +/// Content block types that can appear in a slide +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Block { + /// Heading with level (1-6) and text spans + Heading { level: u8, spans: Vec }, + /// Paragraph of text spans + Paragraph { spans: Vec }, + /// Code block with optional language and content + Code(CodeBlock), + /// Ordered or unordered list + List(List), + /// Horizontal rule/divider + Rule, + /// Block quote + BlockQuote { blocks: Vec }, + /// Table + Table(Table), +} + +/// Styled text span within a block +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TextSpan { + pub text: String, + pub style: TextStyle, +} + +impl TextSpan { + pub fn plain(text: impl Into) -> Self { + Self { + text: text.into(), + style: TextStyle::default(), + } + } + + pub fn bold(text: impl Into) -> Self { + Self { + text: text.into(), + style: TextStyle { + bold: true, + ..Default::default() + }, + } + } + + pub fn italic(text: impl Into) -> Self { + Self { + text: text.into(), + style: TextStyle { + italic: true, + ..Default::default() + }, + } + } + + pub fn code(text: impl Into) -> Self { + Self { + text: text.into(), + style: TextStyle { + code: true, + ..Default::default() + }, + } + } +} + +/// Text styling flags +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct TextStyle { + pub bold: bool, + pub italic: bool, + pub strikethrough: bool, + pub code: bool, +} + +/// Code block with language and content +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodeBlock { + /// Programming language for syntax highlighting + pub language: Option, + /// Raw code content + pub code: String, +} + +impl CodeBlock { + pub fn new(code: impl Into) -> Self { + Self { + language: None, + code: code.into(), + } + } + + pub fn with_language(language: impl Into, code: impl Into) -> Self { + Self { + language: Some(language.into()), + code: code.into(), + } + } +} + +/// List (ordered or unordered) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct List { + pub ordered: bool, + pub items: Vec, +} + +/// Single list item that can contain blocks +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListItem { + pub spans: Vec, + pub nested: Option>, +} + +/// Table with headers and rows +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Table { + pub headers: Vec>, + pub rows: Vec>>, + pub alignments: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Alignment { + Left, + Center, + Right, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slide_creation() { + let slide = Slide::new(); + assert!(slide.is_empty()); + assert_eq!(slide.blocks.len(), 0); + } + + #[test] + fn slide_with_blocks() { + let blocks = vec![Block::Paragraph { + spans: vec![TextSpan::plain("Hello")], + }]; + let slide = Slide::with_blocks(blocks.clone()); + assert!(!slide.is_empty()); + assert_eq!(slide.blocks.len(), 1); + } + + #[test] + fn text_span_styles() { + let plain = TextSpan::plain("text"); + assert!(!plain.style.bold); + assert!(!plain.style.italic); + + let bold = TextSpan::bold("text"); + assert!(bold.style.bold); + + let italic = TextSpan::italic("text"); + assert!(italic.style.italic); + + let code = TextSpan::code("text"); + assert!(code.style.code); + } + + #[test] + fn code_block_creation() { + let code = CodeBlock::new("fn main() {}"); + assert_eq!(code.language, None); + assert_eq!(code.code, "fn main() {}"); + + let rust_code = CodeBlock::with_language("rust", "fn main() {}"); + assert_eq!(rust_code.language, Some("rust".to_string())); + } +} diff --git a/core/src/term.rs b/core/src/term.rs index fd46ef5..255cf24 100644 --- a/core/src/term.rs +++ b/core/src/term.rs @@ -16,10 +16,7 @@ pub struct Terminal { impl Default for Terminal { fn default() -> Self { - Self { - in_alternate_screen: true, - in_raw_mode: true, - } + Self { in_alternate_screen: true, in_raw_mode: true } } } @@ -176,4 +173,29 @@ mod tests { let resize = InputEvent::from_crossterm(Event::Resize(80, 24)); assert_eq!(resize, InputEvent::Resize { width: 80, height: 24 }); } + + #[test] + fn terminal_default_state() { + let terminal = Terminal::default(); + assert!(terminal.in_alternate_screen); + assert!(terminal.in_raw_mode); + } + + #[test] + fn terminal_restore_idempotent() { + let mut terminal = Terminal { in_alternate_screen: false, in_raw_mode: false }; + + assert!(terminal.restore().is_ok()); + assert!(terminal.restore().is_ok()); + assert!(!terminal.in_alternate_screen); + assert!(!terminal.in_raw_mode); + } + + #[test] + fn terminal_restore_clears_flags() { + let mut terminal = Terminal { in_alternate_screen: false, in_raw_mode: false }; + let _ = terminal.restore(); + assert!(!terminal.in_alternate_screen); + assert!(!terminal.in_raw_mode); + } }