#[cfg(test)]
use super::test_filesystem as fs;
use bake::{Error, Result};
use core_markdown::{ParseOptions, mdast::Node, to_mdast};
use serde_yaml_ng::{Mapping, Value};
#[cfg(not(test))]
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
pub(crate) struct Document {
pub(crate) path: PathBuf,
pub(crate) content: String,
pub(crate) frontmatter: Mapping,
node: Option<Node>,
body_start: usize,
}
impl Document {
pub(crate) fn load(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
Self::parse(content, path)
}
pub(crate) fn parse(content: String, path: &Path) -> Result<Self> {
let mut options = ParseOptions::default();
options.constructs.frontmatter = true;
let document = to_mdast(&content, &options)
.expect("Markdown parsing without MDX support is infallible");
let first = document.children().and_then(|children| children.first());
let mut frontmatter = Mapping::new();
let mut node = None;
let mut body_start = 0;
if let Some(Node::Frontmatter(front)) = first {
body_start = front
.position
.as_ref()
.expect("parsed nodes have positions")
.end
.offset;
let remaining = &content[body_start..];
body_start += if remaining.starts_with("\r\n") {
2
} else if remaining.starts_with(['\r', '\n']) {
1
} else {
0
};
if front.language() == Some("yaml") {
let value: Value = serde_yaml_ng::from_str(&front.value).map_err(|error| {
Error::new(format!(
"invalid YAML front matter in {}: {error}",
path.display()
))
})?;
frontmatter = match value {
Value::Null => Mapping::new(),
Value::Mapping(mapping) => mapping,
_ => {
return Err(Error::new(format!(
"context front matter must be a mapping: {}",
path.display()
)));
}
};
}
node = first.cloned();
}
Ok(Self {
path: path.to_owned(),
content,
frontmatter,
node,
body_start,
})
}
pub(crate) fn metadata(&self) -> Option<&Mapping> {
self.frontmatter.get("metadata").and_then(Value::as_mapping)
}
pub(crate) fn is_skill(&self) -> bool {
self.metadata()
.and_then(|metadata| metadata.get("type"))
.and_then(Value::as_str)
== Some("skill")
}
pub(crate) fn write(&self, path: &Path) -> Result<()> {
fs::write(path, &self.content)
.map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
}
pub(crate) fn render(&self, frontmatter: Mapping) -> String {
let Some(Node::Frontmatter(mut node)) = self.node.clone() else {
unreachable!("skill documents have a parsed front matter node");
};
node.value =
serde_yaml_ng::to_string(&frontmatter).expect("parsed YAML mappings are serializable");
let mut output = Node::Frontmatter(node).to_markdown();
output.push_str(&self.content[self.body_start..]);
output
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits_native_frontmatter_and_preserves_the_body_bytes() {
let fixture = include_str!("../tests/fixtures/SKILL.md");
for ending in ["\n", "\r\n"] {
let source = fixture.replace('\n', ending);
let document = Document::parse(source.clone(), Path::new("workflow.md")).unwrap();
assert!(document.is_skill());
let body = &source[document.body_start..];
let mut metadata = document.frontmatter.clone();
metadata.insert("license".into(), "Apache-2.0".into());
let output = document.render(metadata);
let installed = Document::parse(output, Path::new("SKILL.md")).unwrap();
assert_eq!(&installed.content[installed.body_start..], body);
assert_eq!(
installed.frontmatter["license"].as_str(),
Some("Apache-2.0")
);
assert_eq!(
installed.metadata().unwrap()["author"].as_str(),
Some("Provider")
);
}
}
#[test]
fn interprets_yaml_nodes_and_leaves_other_languages_opaque() {
for (source, skill) in [
("--- yaml\nmetadata: {type: skill}\n---\nBody", true),
("``` yaml\nmetadata: {type: skill}\n```\nBody", true),
("--- json\nthis is not YAML [\n---\nBody", false),
("--- yml\nthis is not YAML [\n---\nBody", false),
("+++\nthis is TOML\n+++\nBody", false),
("---\n---", false),
("---\nmetadata: null\n---\n", false),
("---\nmetadata: []\n---\n", false),
("---\nmetadata: 42\n---\n", false),
("---\nmetadata: {type: null}\n---\n", false),
("---\nmetadata: {type: guide}\n---\n", false),
("---\nmetadata: {type: false}\n---\n", false),
("---\ntype: skill\nmetadata: unrelated\n---\n", false),
("# Guide\n\n---\n", false),
] {
let document = Document::parse(source.to_owned(), Path::new("guide.md")).unwrap();
assert_eq!(document.is_skill(), skill, "{source}");
if skill {
assert!(
document
.render(document.frontmatter.clone())
.ends_with("Body")
);
}
}
for source in ["---\n[]\n---\n", "---\n[invalid\n---\n"] {
assert!(
Document::parse(source.to_owned(), Path::new("broken.md"))
.unwrap_err()
.to_string()
.contains("broken.md")
);
}
}
}