use std::collections::BTreeMap;
use carta_ast::{Inline, MetaValue};
use carta_core::{Error, Extension, ReaderOptions, Result};
use super::inline::parse_meta_inlines;
use super::parse_meta_blocks;
use super::yaml::{self, Scalar, Yaml};
pub(crate) struct FrontMatter {
pub(crate) meta: BTreeMap<String, MetaValue>,
pub(crate) body: Option<String>,
}
pub(crate) fn extract(normalized: &str, options: &ReaderOptions) -> Result<FrontMatter> {
if options.extensions.contains(Extension::YamlMetadataBlock)
&& let Some(front) = yaml_block(normalized, options)?
{
return Ok(front);
}
if options.extensions.contains(Extension::PandocTitleBlock)
&& let Some((meta, body)) = title_block(normalized, options)
{
return Ok(FrontMatter {
meta,
body: Some(body),
});
}
if options.extensions.contains(Extension::MmdTitleBlock)
&& let Some((meta, body)) = mmd_title_block(normalized)
{
return Ok(FrontMatter {
meta,
body: Some(body),
});
}
Ok(FrontMatter {
meta: BTreeMap::new(),
body: None,
})
}
fn yaml_block(normalized: &str, options: &ReaderOptions) -> Result<Option<FrontMatter>> {
let lines: Vec<&str> = normalized.split('\n').collect();
if lines.first().is_none_or(|line| line.trim_end() != "---") {
return Ok(None);
}
let close = lines
.iter()
.enumerate()
.skip(1)
.find(|&(_, &line)| {
let line = line.trim_end();
line == "---" || line == "..."
})
.map(|(i, _)| i);
let Some(close) = close else {
return Ok(None);
};
let content = lines.get(1..close).unwrap_or(&[]).join("\n");
match yaml::parse(&content) {
Ok(yaml::TopLevel::Mapping(entries)) => {
let meta = entries
.into_iter()
.map(|(key, value)| (key, yaml_to_meta(value, options)))
.collect();
let body = lines.get(close + 1..).unwrap_or(&[]).join("\n");
Ok(Some(FrontMatter {
meta,
body: Some(body),
}))
}
Ok(yaml::TopLevel::NotMapping) => Ok(None),
Err(()) => Err(Error::InvalidMetadata(
"could not parse YAML metadata block".to_owned(),
)),
}
}
pub(crate) fn parse_metadata_yaml(
content: &str,
options: &ReaderOptions,
) -> Result<BTreeMap<String, MetaValue>> {
match yaml::parse(content) {
Ok(yaml::TopLevel::Mapping(entries)) => Ok(entries
.into_iter()
.map(|(key, value)| (key, yaml_to_meta(value, options)))
.collect()),
Ok(yaml::TopLevel::NotMapping) => Ok(BTreeMap::new()),
Err(()) => Err(Error::InvalidMetadata(
"could not parse YAML metadata file".to_owned(),
)),
}
}
pub(crate) fn parse_metadata_json(
content: &str,
options: &ReaderOptions,
) -> Result<BTreeMap<String, MetaValue>> {
let value: serde_json::Value = serde_json::from_str(content).map_err(|error| {
Error::InvalidMetadata(format!("could not parse JSON metadata file: {error}"))
})?;
match value {
serde_json::Value::Object(map) => Ok(map
.into_iter()
.map(|(key, value)| (key, json_to_meta(value, options)))
.collect()),
_ => Err(Error::InvalidMetadata(
"JSON metadata file must be an object".to_owned(),
)),
}
}
fn json_to_meta(value: serde_json::Value, options: &ReaderOptions) -> MetaValue {
match value {
serde_json::Value::Null => MetaValue::MetaString(String::new()),
serde_json::Value::Bool(b) => MetaValue::MetaBool(b),
serde_json::Value::Number(n) => MetaValue::MetaInlines(parse_meta_inlines(
&n.to_string(),
options.extensions,
options.greedy_paragraphs,
)),
serde_json::Value::String(s) => MetaValue::MetaInlines(parse_meta_inlines(
&s,
options.extensions,
options.greedy_paragraphs,
)),
serde_json::Value::Array(items) => MetaValue::MetaList(
items
.into_iter()
.map(|item| json_to_meta(item, options))
.collect(),
),
serde_json::Value::Object(map) => MetaValue::MetaMap(
map.into_iter()
.map(|(key, item)| (key, json_to_meta(item, options)))
.collect(),
),
}
}
fn yaml_to_meta(value: Yaml, options: &ReaderOptions) -> MetaValue {
match value {
Yaml::Mapping(entries) => MetaValue::MetaMap(
entries
.into_iter()
.map(|(key, value)| (key, yaml_to_meta(value, options)))
.collect(),
),
Yaml::Sequence(items) => MetaValue::MetaList(
items
.into_iter()
.map(|v| yaml_to_meta(v, options))
.collect(),
),
Yaml::Scalar(scalar) => scalar_to_meta(scalar, options),
}
}
fn scalar_to_meta(scalar: Scalar, options: &ReaderOptions) -> MetaValue {
match scalar {
Scalar::Plain(text) => plain_scalar_to_meta(&text, options),
Scalar::Quoted(text) => MetaValue::MetaInlines(parse_meta_inlines(
&text,
options.extensions,
options.greedy_paragraphs,
)),
Scalar::Block(text) => text_to_meta(&text, options),
}
}
fn plain_scalar_to_meta(text: &str, options: &ReaderOptions) -> MetaValue {
if text.is_empty() || is_null(text) {
return MetaValue::MetaString(String::new());
}
if let Some(value) = as_bool(text) {
return MetaValue::MetaBool(value);
}
if let Some(canonical) = yaml::canonicalize_number(text) {
return MetaValue::MetaInlines(parse_meta_inlines(
&canonical,
options.extensions,
options.greedy_paragraphs,
));
}
MetaValue::MetaInlines(parse_meta_inlines(
text,
options.extensions,
options.greedy_paragraphs,
))
}
fn text_to_meta(text: &str, options: &ReaderOptions) -> MetaValue {
if text.ends_with('\n') {
MetaValue::MetaBlocks(parse_meta_blocks(
text,
options.extensions,
options.greedy_paragraphs,
))
} else {
MetaValue::MetaInlines(parse_meta_inlines(
text,
options.extensions,
options.greedy_paragraphs,
))
}
}
fn is_null(text: &str) -> bool {
matches!(text, "null" | "Null" | "NULL" | "~")
}
fn as_bool(text: &str) -> Option<bool> {
match text {
"y" | "Y" | "yes" | "Yes" | "YES" | "true" | "True" | "TRUE" | "on" | "On" | "ON" => {
Some(true)
}
"n" | "N" | "no" | "No" | "NO" | "false" | "False" | "FALSE" | "off" | "Off" | "OFF" => {
Some(false)
}
_ => None,
}
}
fn title_block(
normalized: &str,
options: &ReaderOptions,
) -> Option<(BTreeMap<String, MetaValue>, String)> {
let lines: Vec<&str> = normalized.split('\n').collect();
if !lines.first().is_some_and(|line| line.starts_with('%')) {
return None;
}
let labels = ["title", "author", "date"];
let mut meta = BTreeMap::new();
let mut idx = 0;
for label in labels {
let Some(&line) = lines.get(idx) else { break };
if !line.starts_with('%') {
break;
}
let mut field = vec![strip_field_marker(line).to_owned()];
idx += 1;
while let Some(&cont) = lines.get(idx) {
if cont.starts_with('%') || cont.trim().is_empty() || !starts_with_space(cont) {
break;
}
field.push(cont.trim().to_owned());
idx += 1;
}
insert_field(&mut meta, label, &field, options);
}
let body = lines.get(idx..).unwrap_or(&[]).join("\n");
Some((meta, body))
}
fn mmd_title_block(normalized: &str) -> Option<(BTreeMap<String, MetaValue>, String)> {
let lines: Vec<&str> = normalized.split('\n').collect();
let (mut key, value) = lines.first().and_then(|line| meta_key_line(line))?;
if value.trim().is_empty() {
return None;
}
let mut meta = BTreeMap::new();
let mut field = vec![value.trim().to_owned()];
let mut idx = 1;
let mut terminator = None;
while let Some(&line) = lines.get(idx) {
if line.trim().is_empty() {
terminator = Some(idx);
break;
}
if let Some((next_key, next_value)) = meta_key_line(line) {
insert_meta_field(&mut meta, &key, &field);
key = next_key;
field = vec![next_value.trim().to_owned()];
} else if starts_with_space(line) {
field.push(line.trim().to_owned());
} else {
return None;
}
idx += 1;
}
insert_meta_field(&mut meta, &key, &field);
let body = match terminator {
Some(blank) => lines.get(blank + 1..).unwrap_or(&[]).join("\n"),
None => String::new(),
};
Some((meta, body))
}
fn meta_key_line(line: &str) -> Option<(String, &str)> {
let colon = line.find(':')?;
let raw = line.get(..colon)?;
if !raw
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, ' ' | '\t' | '-' | '_'))
{
return None;
}
let key: String = raw
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(char::to_lowercase)
.collect();
if key.is_empty() {
return None;
}
Some((key, line.get(colon + 1..).unwrap_or("")))
}
fn insert_meta_field(meta: &mut BTreeMap<String, MetaValue>, key: &str, field: &[String]) {
let mut inlines = Vec::new();
for line in field {
if !inlines.is_empty() {
inlines.push(Inline::SoftBreak);
}
for (word_index, word) in line.split_whitespace().enumerate() {
if word_index > 0 {
inlines.push(Inline::Space);
}
inlines.push(Inline::Str(word.to_owned()));
}
}
meta.insert(key.to_owned(), MetaValue::MetaInlines(inlines));
}
fn insert_field(
meta: &mut BTreeMap<String, MetaValue>,
label: &str,
field: &[String],
options: &ReaderOptions,
) {
if label == "author" {
let mut authors = Vec::new();
for line in field {
for chunk in line.split(';') {
authors.push(MetaValue::MetaInlines(parse_meta_inlines(
chunk.trim(),
options.extensions,
options.greedy_paragraphs,
)));
}
}
meta.insert("author".to_owned(), MetaValue::MetaList(authors));
return;
}
let text = field.join("\n");
if !text.trim().is_empty() {
meta.insert(
label.to_owned(),
MetaValue::MetaInlines(parse_meta_inlines(
&text,
options.extensions,
options.greedy_paragraphs,
)),
);
}
}
fn strip_field_marker(line: &str) -> &str {
let rest = line.strip_prefix('%').unwrap_or(line);
rest.strip_prefix(' ').unwrap_or(rest)
}
fn starts_with_space(line: &str) -> bool {
line.starts_with([' ', '\t'])
}
#[cfg(test)]
mod tests {
use crate::commonmark::CommonmarkReader;
use carta_ast::{Block, MetaValue};
use carta_core::{Extension, Extensions, Reader, ReaderOptions};
fn read(input: &str) -> carta_ast::Document {
let mut options = ReaderOptions::default();
let mut extensions = Extensions::empty();
extensions.insert(Extension::YamlMetadataBlock);
options.extensions = extensions;
CommonmarkReader
.read(input, &options)
.expect("reader should not fail")
}
#[test]
fn fence_lines_tolerate_trailing_whitespace() {
let document = read("--- \ntitle: T\n---\t\n\nBody\n");
assert_eq!(
document.meta.get("title"),
Some(&MetaValue::MetaInlines(vec![carta_ast::Inline::Str(
"T".to_owned()
)]))
);
assert!(matches!(document.blocks.as_slice(), [Block::Para(_)]));
}
#[test]
fn closing_ellipsis_fence_tolerates_trailing_whitespace() {
let document = read("---\ntitle: T\n... \n\nBody\n");
assert!(document.meta.contains_key("title"));
}
#[test]
fn an_indented_opening_fence_is_not_front_matter() {
let document = read(" ---\ntitle: T\n---\n\nBody\n");
assert!(document.meta.is_empty());
}
fn read_mmd(input: &str) -> carta_ast::Document {
let mut options = ReaderOptions::default();
let mut extensions = Extensions::empty();
extensions.insert(Extension::MmdTitleBlock);
options.extensions = extensions;
CommonmarkReader
.read(input, &options)
.expect("reader should not fail")
}
fn inlines(words: &[&str]) -> MetaValue {
use carta_ast::Inline;
let mut out = Vec::new();
for (index, word) in words.iter().enumerate() {
if index > 0 {
out.push(Inline::Space);
}
out.push(Inline::Str((*word).to_owned()));
}
MetaValue::MetaInlines(out)
}
#[test]
fn mmd_title_block_populates_meta_and_strips_the_body() {
let document = read_mmd("Title: My Doc\nBase Header Level: 2\n\nBody.\n");
assert_eq!(document.meta.get("title"), Some(&inlines(&["My", "Doc"])));
assert_eq!(document.meta.get("baseheaderlevel"), Some(&inlines(&["2"])));
assert!(matches!(document.blocks.as_slice(), [Block::Para(_)]));
}
#[test]
fn mmd_title_block_values_are_not_markdown() {
let document = read_mmd("Title: *Emph* `code`\n\nBody.\n");
assert_eq!(
document.meta.get("title"),
Some(&inlines(&["*Emph*", "`code`"]))
);
}
#[test]
fn mmd_title_block_continuation_lines_join_with_soft_breaks() {
use carta_ast::Inline;
let document = read_mmd("Author: Jane\n John\n\nBody.\n");
assert_eq!(
document.meta.get("author"),
Some(&MetaValue::MetaInlines(vec![
Inline::Str("Jane".to_owned()),
Inline::SoftBreak,
Inline::Str("John".to_owned()),
]))
);
}
#[test]
fn mmd_title_block_requires_a_first_value_and_well_formed_lines() {
assert!(read_mmd("Foo:\nTitle: X\n\nBody.\n").meta.is_empty());
assert!(read_mmd("Title: X\nNot a key\n\nBody.\n").meta.is_empty());
assert!(read_mmd("a/b: v\n\nBody.\n").meta.is_empty());
}
}