diff --git a/Cargo.lock b/Cargo.lock
index a336479222..fcc04dfd76 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -904,6 +904,16 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+[[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.12.1"
@@ -1074,6 +1084,7 @@ dependencies = [
"regex",
"serde",
"serde_json",
+ "serde_yml",
"sha2 0.11.0",
"tempfile",
"toml",
@@ -1748,6 +1759,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 87d8605ba5..2f8e2b3332 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -128,6 +128,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 f8b51c21d6..3947fef76e 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/front-end/templates/index.hbs b/crates/mdbook-html/front-end/templates/index.hbs
index b1834189f9..35098a1fb9 100644
--- a/crates/mdbook-html/front-end/templates/index.hbs
+++ b/crates/mdbook-html/front-end/templates/index.hbs
@@ -15,10 +15,34 @@
{{> head}}
-
+
+ {{#if is_frontmatter}}
+
+
+
+
+ {{else}}
+
+
+
+
+ {{/if}}
+ {{#if og_image_url}}
+
+
+ {{else}}
+
+
+ {{/if}}
+
+
+
+
+
+
{{#if favicon_svg}}
{{/if}}
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;
diff --git a/tests/testsuite/config.rs b/tests/testsuite/config.rs
index 641243c103..af40370afd 100644
--- a/tests/testsuite/config.rs
+++ b/tests/testsuite/config.rs
@@ -279,7 +279,7 @@ fn env_entire_book_table() {
.check_file_contains("book/index.html", "Chapter 1")
.check_file_contains(
"book/index.html",
- r#""#,
+ r#""#,
);
}