diff --git a/Cargo.lock b/Cargo.lock index 0b35d30c61..dee7f85e5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -862,6 +862,16 @@ dependencies = [ "redox_syscall", ] +[[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.11.0" @@ -1035,6 +1045,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_yml", "sha2", "tempfile", "toml", @@ -1705,6 +1716,21 @@ dependencies = [ "serde", ] +[[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 = "sha1" version = "0.10.6" diff --git a/Cargo.toml b/Cargo.toml index 2d97562e42..20af504772 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,6 +131,7 @@ default = ["watch", "serve", "search"] watch = ["dep:notify", "dep:notify-debouncer-mini", "dep:ignore", "dep:pathdiff", "dep:walkdir"] serve = ["dep:futures-util", "dep:tokio", "dep:axum", "dep:tower-http"] search = ["mdbook-html/search"] +frontmatter = ["mdbook-html/frontmatter"] [[bin]] doc = false diff --git a/crates/mdbook-html/Cargo.toml b/crates/mdbook-html/Cargo.toml index 7ca8b08090..931734d3bc 100644 --- a/crates/mdbook-html/Cargo.toml +++ b/crates/mdbook-html/Cargo.toml @@ -23,6 +23,7 @@ pulldown-cmark.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true +serde_yml = { version = "0.0.12", optional = true } sha2.workspace = true tracing.workspace = true @@ -35,3 +36,4 @@ workspace = true [features] search = ["dep:elasticlunr-rs"] +frontmatter = ["dep:serde_yml"] diff --git a/crates/mdbook-html/src/frontmatter.rs b/crates/mdbook-html/src/frontmatter.rs new file mode 100644 index 0000000000..08b02e8eb7 --- /dev/null +++ b/crates/mdbook-html/src/frontmatter.rs @@ -0,0 +1,101 @@ +//! Frontmatter parsing support for mdBook. +//! +//! Extracts YAML frontmatter from markdown content and injects +//! Open Graph / Twitter Card metadata into the Handlebars template context. + +use serde::Deserialize; +use serde_json::json; + +/// Parsed YAML frontmatter fields. +#[derive(Deserialize, Debug)] +pub(crate) struct FrontMatter { + /// Page title for OG/Twitter meta tags. + pub title: String, + /// Page description for OG/Twitter meta tags. + pub description: String, + /// Featured image URL for OG/Twitter meta tags. + pub featured_image_url: String, +} + +/// Strips YAML frontmatter (between `---` markers) from content, +/// returning the content without the frontmatter block. +pub(crate) fn strip_frontmatter(content: &str) -> String { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return content.to_string(); + } + // Find the closing `---` after the opening one + let after_open = &trimmed[3..]; + if let Some(end) = after_open.find("\n---") { + // Skip past the closing `---` and any trailing newline + let rest = &after_open[end + 4..]; + rest.trim_start_matches('\n').to_string() + } else { + content.to_string() + } +} + +/// Parses YAML frontmatter from content and injects OG metadata +/// into the Handlebars template context data map. +pub(crate) fn inject_frontmatter_data( + content: &str, + data: &mut serde_json::Map, +) { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return; + } + let after_open = &trimmed[3..]; + let Some(end) = after_open.find("\n---") else { + return; + }; + let yaml_str = &after_open[..end]; + + match serde_yml::from_str::(yaml_str) { + Ok(fm) => { + data.insert("is_frontmatter".to_owned(), json!(true)); + data.insert("og_title".to_owned(), json!(fm.title)); + data.insert("og_description".to_owned(), json!(fm.description)); + data.insert("og_image_url".to_owned(), json!(fm.featured_image_url)); + } + Err(e) => { + eprintln!("Frontmatter: deserialization error: {e:?}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_frontmatter() { + let input = "---\ntitle: \"Hello\"\n---\n# Content"; + assert_eq!(strip_frontmatter(input), "# Content"); + } + + #[test] + fn test_strip_no_frontmatter() { + let input = "# Just content"; + assert_eq!(strip_frontmatter(input), "# Just content"); + } + + #[test] + fn test_inject_frontmatter_data() { + let input = "---\ntitle: \"My Title\"\ndescription: \"My Desc\"\nfeatured_image_url: \"https://example.com/img.png\"\n---\n# Content"; + let mut data = serde_json::Map::new(); + inject_frontmatter_data(input, &mut data); + assert_eq!(data["is_frontmatter"], json!(true)); + assert_eq!(data["og_title"], json!("My Title")); + assert_eq!(data["og_description"], json!("My Desc")); + assert_eq!(data["og_image_url"], json!("https://example.com/img.png")); + } + + #[test] + fn test_inject_no_frontmatter() { + let input = "# Just content"; + let mut data = serde_json::Map::new(); + inject_frontmatter_data(input, &mut data); + assert!(!data.contains_key("is_frontmatter")); + } +} diff --git a/crates/mdbook-html/src/html/mod.rs b/crates/mdbook-html/src/html/mod.rs index 8a70700f7e..626ffeeeb6 100644 --- a/crates/mdbook-html/src/html/mod.rs +++ b/crates/mdbook-html/src/html/mod.rs @@ -96,7 +96,11 @@ pub(crate) fn build_trees<'book>( let path = ch.path.as_ref().unwrap(); let html_path = ch.path.as_ref().unwrap().with_extension("html"); let options = HtmlRenderOptions::new(path, html_config, edition); - let tree = build_tree(&ch.content, &options); + #[cfg(feature = "frontmatter")] + let chapter_content = crate::frontmatter::strip_frontmatter(&ch.content); + #[cfg(not(feature = "frontmatter"))] + let chapter_content = ch.content.clone(); + let tree = build_tree(&chapter_content, &options); ChapterTree { chapter: ch, diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index 8edac3cace..ef39aef145 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -115,6 +115,10 @@ impl HtmlHandlebars { nav("previous", prev_ch); nav("next", next_ch); + // Inject frontmatter OG metadata into template context + #[cfg(feature = "frontmatter")] + crate::frontmatter::inject_frontmatter_data(&ch.content, &mut ctx.data); + // Render the handlebars template with the data debug!("Render template"); let rendered = ctx.handlebars.render("index", &ctx.data)?; diff --git a/crates/mdbook-html/src/lib.rs b/crates/mdbook-html/src/lib.rs index 9bb7f00851..d012d395dc 100644 --- a/crates/mdbook-html/src/lib.rs +++ b/crates/mdbook-html/src/lib.rs @@ -5,4 +5,7 @@ mod html_handlebars; pub mod theme; pub(crate) mod utils; +#[cfg(feature = "frontmatter")] +pub(crate) mod frontmatter; + pub use html_handlebars::HtmlHandlebars;