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);
+ }
}