use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedDocument {
pub frontmatter: HashMap<String, serde_yaml::Value>,
pub body: String,
pub frontmatter_range: Option<(usize, usize)>,
#[serde(default)]
pub frontmatter_error: Option<String>,
}
impl ParsedDocument {
#[allow(clippy::string_slice)]
pub fn render_body(&self) -> &str {
match (self.frontmatter_error.as_ref(), self.frontmatter_range) {
(Some(_), Some((_, fm_end))) => &self.body[fm_end..],
_ => &self.body,
}
}
}
pub fn parse(content: &str) -> ParsedDocument {
let owned;
let content = if content.contains("\r\n") {
owned = content.replace("\r\n", "\n");
owned.as_str()
} else {
content
};
if !content.starts_with("---") {
return ParsedDocument {
frontmatter: HashMap::new(),
body: content.to_string(),
frontmatter_range: None,
frontmatter_error: None,
};
}
let after_opening = match content.find('\n') {
Some(pos) => pos + 1,
None => {
return ParsedDocument {
frontmatter: HashMap::new(),
body: content.to_string(),
frontmatter_range: None,
frontmatter_error: None,
};
}
};
#[allow(clippy::string_slice)]
let rest = &content[after_opening..];
let mut offset = 0;
for line in rest.lines() {
if line.trim() == "---" {
let close_line_start = after_opening + offset;
let close_line_end = close_line_start + line.len();
let fm_end = if close_line_end < content.len()
&& content.as_bytes()[close_line_end] == b'\n'
{
close_line_end + 1
} else {
close_line_end
};
#[allow(clippy::string_slice)]
let yaml_text = &content[after_opening..close_line_start];
let frontmatter: HashMap<String, serde_yaml::Value> =
match serde_yaml::from_str(yaml_text) {
Ok(map) => map,
Err(e) => {
return ParsedDocument {
frontmatter: HashMap::new(),
body: content.to_string(),
frontmatter_range: Some((0, fm_end)),
frontmatter_error: Some(e.to_string()),
};
}
};
#[allow(clippy::string_slice)]
let body = &content[fm_end..];
return ParsedDocument {
frontmatter,
body: body.to_string(),
frontmatter_range: Some((0, fm_end)),
frontmatter_error: None,
};
}
offset += line.len() + 1; }
ParsedDocument {
frontmatter: HashMap::new(),
body: content.to_string(),
frontmatter_range: None,
frontmatter_error: None,
}
}
pub fn serialize(
frontmatter: &HashMap<String, serde_yaml::Value>,
body: &str,
) -> Result<String, String> {
if frontmatter.is_empty() {
return Ok(body.to_string());
}
let safe_fm: HashMap<String, serde_yaml::Value> = frontmatter
.iter()
.map(|(k, v)| (k.clone(), ensure_strings_quoted(&strip_control_chars(v))))
.collect();
let yaml =
serde_yaml::to_string(&safe_fm).map_err(|e| format!("YAML serialize error: {}", e))?;
Ok(format!("---\n{}---\n{}", yaml, body))
}
fn strip_control_chars(value: &serde_yaml::Value) -> serde_yaml::Value {
match value {
serde_yaml::Value::String(s) => serde_yaml::Value::String(strip_control_chars_str(s)),
serde_yaml::Value::Sequence(seq) => {
serde_yaml::Value::Sequence(seq.iter().map(strip_control_chars).collect())
}
serde_yaml::Value::Mapping(map) => {
let mut new_map = serde_yaml::Mapping::new();
for (k, v) in map {
new_map.insert(k.clone(), strip_control_chars(v));
}
serde_yaml::Value::Mapping(new_map)
}
other => other.clone(),
}
}
fn is_stray_control_char(c: char) -> bool {
matches!(c as u32,
0x00..=0x08 | 0x0b..=0x0c | 0x0e..=0x1f | 0x7f..=0x9f
)
}
pub fn strip_control_chars_str(s: &str) -> String {
s.chars().filter(|c| !is_stray_control_char(*c)).collect()
}
fn ensure_strings_quoted(value: &serde_yaml::Value) -> serde_yaml::Value {
match value {
serde_yaml::Value::Sequence(seq) => {
serde_yaml::Value::Sequence(seq.iter().map(ensure_strings_quoted).collect())
}
serde_yaml::Value::Mapping(map) => {
let mut new_map = serde_yaml::Mapping::new();
for (k, v) in map {
new_map.insert(k.clone(), ensure_strings_quoted(v));
}
serde_yaml::Value::Mapping(new_map)
}
other => other.clone(),
}
}
pub fn value_as_string(value: &serde_yaml::Value) -> Option<String> {
match value {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(format!("{}", n)),
serde_yaml::Value::Bool(b) => Some(format!("{}", b)),
_ => None,
}
}
pub fn frontmatter_asset_spans(source: &str) -> Vec<crate::resolve::md_extract::AssetPathSpan> {
use crate::resolve::md_extract::{AssetPathSpan, PathContainer};
let mut out = Vec::new();
let table = crate::resolve::md_extract::line_table(source);
let line_at = |k: usize| -> &str {
let (base, content, _) = table[k];
#[allow(clippy::string_slice)]
&source[base..base + content]
};
if table.is_empty() || line_at(0).trim() != "---" {
return out;
}
let Some(close) = (1..table.len()).find(|&k| line_at(k).trim() == "---") else {
return out;
};
let keys: Vec<&str> = crate::schema_fields::asset_field_names().collect();
let mut block_scalar_indent: Option<usize> = None;
for k in 1..close {
let (base, content_len, term_len) = table[k];
let line = line_at(k);
let indent = line.len() - line.trim_start().len();
if let Some(bi) = block_scalar_indent {
if line.trim().is_empty() || indent > bi {
continue;
}
block_scalar_indent = None;
}
if line.trim().is_empty() {
continue;
}
let Some(colon) = line.find(':') else { continue };
#[allow(clippy::string_slice)]
let key = line[indent..colon].trim();
#[allow(clippy::string_slice)]
let after = &line[colon + 1..];
let t = after.trim();
if t.starts_with('|') || t.starts_with('>') {
#[allow(clippy::string_slice)]
let tail = t[1..].trim_start_matches(['+', '-']);
if tail.chars().all(|c| c.is_ascii_digit()) {
block_scalar_indent = Some(indent);
continue;
}
}
if !keys.contains(&key) {
continue;
}
let vrel = colon + 1 + (after.len() - after.trim_start().len());
if vrel >= content_len {
continue;
}
#[allow(clippy::string_slice)]
let raw_tail = &line[vrel..];
let first = raw_tail.as_bytes()[0];
if first == b'[' || first == b'{' {
continue;
}
let (value_len, quote) = match first {
b'"' => (scan_quoted(raw_tail, '"'), Some('"')),
b'\'' => (scan_quoted(raw_tail, '\''), Some('\'')),
_ => (scan_plain(raw_tail), None),
};
let Some(value_len) = value_len else { continue };
#[allow(clippy::string_slice)]
let raw = &raw_tail[..value_len];
let inner = match quote {
Some('"') => unescape_double(raw),
Some('\'') => raw
.trim_matches('\'')
.replace("''", "'"),
_ => raw.to_string(),
};
if inner.trim().is_empty() {
continue;
}
let (path, attrs) = crate::media::split_pipe(&inner);
let path = crate::media::strip_wikilink(path).trim().to_string();
if path.is_empty() {
continue;
}
out.push(AssetPathSpan {
path,
attrs: attrs.to_string(),
quote,
value: base + vrel..base + vrel + value_len,
outer: base..base + content_len + term_len,
container: PathContainer::FrontmatterField {
key: key.to_string(),
},
});
}
out
}
fn scan_quoted(s: &str, q: char) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = 1;
while i < bytes.len() {
if q == '"' && bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] == q as u8 {
if q == '\'' && bytes.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
return Some(i + 1);
}
i += 1;
}
None
}
fn scan_plain(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut end = bytes.len();
for i in 0..bytes.len() {
if bytes[i] == b'#' && i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
end = i;
break;
}
}
while end > 0 && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\t') {
end -= 1;
}
if end == 0 {
None
} else {
Some(end)
}
}
fn unescape_double(raw: &str) -> String {
let inner = raw
.strip_prefix('"')
.and_then(|r| r.strip_suffix('"'))
.unwrap_or(raw);
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(n) = chars.next() {
out.push(n);
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
#[path = "frontmatter_tests.rs"]
mod tests;