use std::path::{Path, PathBuf};
use crate::error::Result;
use crate::meta::{self, Value};
pub use fig::EmbedType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Body {
pub text: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetaCarrier {
Fenced(EmbedType),
WholeFile(fig::Format),
}
impl MetaCarrier {
pub fn format(&self) -> fig::Format {
match self {
MetaCarrier::Fenced(kind) => kind.inner_format(),
MetaCarrier::WholeFile(format) => *format,
}
}
}
pub fn whole_file_format(path: &Path) -> Option<fig::Format> {
match path.extension()?.to_str()? {
#[cfg(feature = "yaml")]
"yaml" | "yml" => Some(fig::Format::Yaml),
#[cfg(feature = "json")]
"json" => Some(fig::Format::Json),
#[cfg(feature = "toml")]
"toml" => Some(fig::Format::Toml),
#[cfg(feature = "fig-lang")]
"fig" | "figl" => Some(fig::Format::Fig),
_ => None,
}
}
pub fn require_whole_file(path: &Path, carrier: MetaCarrier) -> Result<fig::Format> {
match carrier {
MetaCarrier::WholeFile(format) => Ok(format),
MetaCarrier::Fenced(_) => Err(crate::error::Error::MarkdownStore(path.to_path_buf())),
}
}
pub fn is_opaque_payload(path: &Path) -> bool {
crate::content::ContentFormat::from_extension(path).is_none()
&& whole_file_format(path).is_none()
}
pub fn whole_file_extension(format: fig::Format) -> &'static str {
match format {
#[cfg(feature = "json")]
fig::Format::Json => "json",
#[cfg(feature = "toml")]
fig::Format::Toml => "toml",
#[cfg(feature = "fig-lang")]
fig::Format::Fig => "figl",
_ => "yaml",
}
}
pub fn frontmatter_carrier(format: fig::Format) -> MetaCarrier {
let embed = match format {
#[cfg(feature = "json")]
fig::Format::Json => EmbedType::FrontmatterJson,
#[cfg(feature = "toml")]
fig::Format::Toml => EmbedType::PlusToml,
#[cfg(feature = "fig-lang")]
fig::Format::Fig => EmbedType::FrontmatterFig,
_ => EmbedType::FrontmatterYaml,
};
MetaCarrier::Fenced(embed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmbedStyle {
Delimited,
CodeBlock,
HtmlScript,
HtmlCode,
Separate,
}
impl EmbedStyle {
pub fn as_config_str(self) -> &'static str {
match self {
EmbedStyle::Delimited => "delimited",
EmbedStyle::CodeBlock => "code_block",
EmbedStyle::HtmlScript => "html_script",
EmbedStyle::HtmlCode => "html_code",
EmbedStyle::Separate => "separate",
}
}
pub fn from_config_str(value: &str) -> Option<Self> {
Some(match value {
"delimited" => EmbedStyle::Delimited,
"code_block" => EmbedStyle::CodeBlock,
"html_script" => EmbedStyle::HtmlScript,
"html_code" => EmbedStyle::HtmlCode,
"separate" => EmbedStyle::Separate,
_ => return None,
})
}
}
pub fn embed_style_of(kind: EmbedType) -> EmbedStyle {
use EmbedType as E;
match kind {
E::FrontmatterYaml
| E::FrontmatterJson
| E::PlusToml
| E::MdFrontmatterJson
| E::MdFrontmatterToml
| E::MdFrontmatterFig => EmbedStyle::Delimited,
E::EndmatterYaml | E::FencedYaml | E::FencedJson | E::FencedToml | E::FrontmatterFig => {
EmbedStyle::CodeBlock
}
E::HtmlScriptYaml | E::HtmlScriptJson | E::HtmlScriptToml | E::HtmlScriptFig => {
EmbedStyle::HtmlScript
}
E::HtmlCodeYaml | E::HtmlCodeJson | E::HtmlCodeToml | E::HtmlCodeFig => {
EmbedStyle::HtmlCode
}
_ => unreachable!("unhandled fig::EmbedType variant — add a case in embed_style_of"),
}
}
pub fn embed_carrier(style: EmbedStyle, format: fig::Format) -> Option<MetaCarrier> {
use EmbedType as E;
use fig::Format as F;
let is_json = matches!(format, F::Json | F::Jsonc | F::Json5);
let kind = match style {
EmbedStyle::Separate => return Some(MetaCarrier::WholeFile(format)),
EmbedStyle::Delimited => match format {
F::Yaml => E::FrontmatterYaml,
F::Toml => E::PlusToml,
_ if is_json => E::FrontmatterJson,
_ => return None,
},
EmbedStyle::CodeBlock => match format {
F::Yaml => E::FencedYaml,
F::Toml => E::FencedToml,
F::Fig => E::FrontmatterFig,
_ if is_json => E::FencedJson,
_ => return None,
},
EmbedStyle::HtmlScript => match format {
F::Yaml => E::HtmlScriptYaml,
F::Toml => E::HtmlScriptToml,
F::Fig => E::HtmlScriptFig,
_ if is_json => E::HtmlScriptJson,
_ => return None,
},
EmbedStyle::HtmlCode => match format {
F::Yaml => E::HtmlCodeYaml,
F::Toml => E::HtmlCodeToml,
F::Fig => E::HtmlCodeFig,
_ if is_json => E::HtmlCodeJson,
_ => return None,
},
};
Some(MetaCarrier::Fenced(kind))
}
#[derive(Debug, Clone)]
pub struct Document {
pub path: PathBuf,
pub meta: Value,
pub body: String,
pub carrier: Option<MetaCarrier>,
}
impl Document {
pub fn parse(path: impl Into<PathBuf>, text: &str) -> Result<Self> {
let path = path.into();
if let Some(format) = whole_file_format(&path) {
let meta = meta::parse_value(text, format)?;
return Ok(Self {
path,
meta,
body: String::new(),
carrier: Some(MetaCarrier::WholeFile(format)),
});
}
let (meta, body, carrier) = match fig::detect(text) {
Some(kind) => match fig::Embed::extract(text, kind) {
Ok(found) => (
meta::parse_value(found.content(), kind.inner_format())?,
[found.host_before(), found.host_after()].concat(),
Some(MetaCarrier::Fenced(kind)),
),
Err(_) => (Value::Null, text.to_owned(), None),
},
None => (Value::Null, text.to_owned(), None),
};
Ok(Self {
path,
meta,
body,
carrier,
})
}
pub fn split(text: &str) -> Option<(MetaCarrier, &str, &str, &str)> {
let kind = fig::detect(text)?;
let found = fig::Embed::extract(text, kind).ok()?;
Some((
MetaCarrier::Fenced(kind),
found.content(),
found.host_before(),
found.host_after(),
))
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn has_meta(&self) -> bool {
self.meta.as_mapping().is_some()
}
pub fn content_attr(&self) -> Option<&str> {
self.meta.get("content").and_then(Value::as_str)
}
pub fn manifest_attr(&self) -> Option<&str> {
self.meta
.get(crate::manifest::MANIFEST_KEY)
.and_then(Value::as_str)
}
pub fn is_manifest_node(&self) -> bool {
self.manifest_attr().is_some()
}
pub fn manifest_conflicts(&self) -> bool {
self.content_attr().is_some() && self.manifest_attr().is_some()
}
pub fn content_path(&self, doc_path: &Path) -> Option<PathBuf> {
let dir = doc_path.parent().unwrap_or(Path::new(""));
Some(crate::link::normalize(dir.join(self.content_attr()?)))
}
pub fn body_path(&self, doc_path: &Path) -> PathBuf {
self.content_path(doc_path)
.unwrap_or_else(|| doc_path.to_path_buf())
}
pub fn is_attachment(&self) -> bool {
match self.content_attr() {
None => false,
Some(content) => {
self.meta.get("attachment").and_then(Value::as_bool) == Some(true)
|| is_opaque_payload(Path::new(content))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "yaml")]
#[test]
fn content_path_joins_the_target_onto_the_node_s_own_directory() {
let doc = Document::parse("notes/a.yaml", "title: A\ncontent: a.md\n").unwrap();
assert_eq!(
doc.content_path(Path::new("notes/a.yaml")),
Some(PathBuf::from("notes/a.md"))
);
assert_eq!(
doc.body_path(Path::new("notes/a.yaml")),
PathBuf::from("notes/a.md")
);
}
#[cfg(feature = "yaml")]
#[test]
fn content_path_resolves_an_absolute_node_path_as_absolute() {
let doc = Document::parse("/vault/notes/a.yaml", "title: A\ncontent: a.md\n").unwrap();
assert_eq!(
doc.content_path(Path::new("/vault/notes/a.yaml")),
Some(PathBuf::from("/vault/notes/a.md"))
);
}
#[cfg(feature = "yaml")]
#[test]
fn a_combined_document_has_no_content_path_and_is_its_own_body_path() {
let doc = Document::parse("notes/a.md", "---\ntitle: A\n---\nprose\n").unwrap();
assert_eq!(doc.content_path(Path::new("notes/a.md")), None);
assert_eq!(
doc.body_path(Path::new("notes/a.md")),
PathBuf::from("notes/a.md")
);
}
#[cfg(feature = "yaml")]
#[test]
fn parses_yaml_frontmatter_and_body() {
let text = "---\ntitle: Root\ncontents:\n- a.md\n---\n# Body\n\nhello\n";
let doc = Document::parse("index.md", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
assert_eq!(doc.body, "# Body\n\nhello\n");
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::FrontmatterYaml))
);
assert!(doc.has_meta());
}
#[cfg(feature = "fig-lang")]
#[test]
fn parses_fig_fenced_frontmatter() {
let text = "```fig\ntitle = prov\ncontents = [docs/design.md]\n```\n# Body\n";
let doc = Document::parse("README.md", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("prov"));
assert_eq!(doc.body, "# Body\n");
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::FrontmatterFig))
);
assert!(doc.has_meta());
}
#[cfg(feature = "json")]
#[test]
fn parses_json_frontmatter() {
let text = ";;;\n{\"title\": \"Root\"}\n;;;\nbody\n";
let doc = Document::parse("note.md", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::FrontmatterJson))
);
}
#[cfg(feature = "yaml")]
#[test]
fn parses_yaml_endmatter() {
let text = "# Body first\n```endmatter\ntitle: Tail\n```\n";
let doc = Document::parse("note.md", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Tail"));
assert_eq!(doc.body, "# Body first\n");
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::EndmatterYaml))
);
}
#[cfg(feature = "yaml")]
#[test]
fn a_config_file_is_a_document_whose_content_is_all_metadata() {
let text = "title: ID registry\npart_of: index.md\nregistry:\n abc: a.md\n";
let doc = Document::parse("registry.yaml", text).unwrap();
assert_eq!(
doc.meta.get("title").and_then(Value::as_str),
Some("ID registry")
);
assert_eq!(
doc.meta.get("part_of").and_then(Value::as_str),
Some("index.md")
);
assert_eq!(doc.body, "");
assert_eq!(doc.carrier, Some(MetaCarrier::WholeFile(fig::Format::Yaml)));
assert!(doc.has_meta());
}
#[cfg(feature = "fig-lang")]
#[test]
fn a_fig_config_file_parses_the_dialect() {
let text = "title = settings\npart_of = index.md\n";
let doc = Document::parse("settings.figl", text).unwrap();
assert_eq!(
doc.meta.get("title").and_then(Value::as_str),
Some("settings")
);
assert_eq!(doc.carrier, Some(MetaCarrier::WholeFile(fig::Format::Fig)));
}
#[test]
fn embed_style_config_str_round_trips() {
for style in [
EmbedStyle::Delimited,
EmbedStyle::CodeBlock,
EmbedStyle::HtmlScript,
EmbedStyle::HtmlCode,
EmbedStyle::Separate,
] {
assert_eq!(
EmbedStyle::from_config_str(style.as_config_str()),
Some(style)
);
}
assert_eq!(EmbedStyle::from_config_str("nonsense"), None);
}
#[test]
fn embed_carrier_resolves_style_and_format_to_a_carrier() {
use fig::Format;
let fenced = |k| Some(MetaCarrier::Fenced(k));
assert_eq!(
embed_carrier(EmbedStyle::Delimited, Format::Yaml),
fenced(EmbedType::FrontmatterYaml)
);
assert_eq!(
embed_carrier(EmbedStyle::Delimited, Format::Toml),
fenced(EmbedType::PlusToml)
);
assert_eq!(
embed_carrier(EmbedStyle::Delimited, Format::Json),
fenced(EmbedType::FrontmatterJson)
);
assert_eq!(embed_carrier(EmbedStyle::Delimited, Format::Fig), None);
assert_eq!(
embed_carrier(EmbedStyle::CodeBlock, Format::Fig),
fenced(EmbedType::FrontmatterFig)
);
assert_eq!(
embed_carrier(EmbedStyle::CodeBlock, Format::Yaml),
fenced(EmbedType::FencedYaml)
);
assert_eq!(
embed_carrier(EmbedStyle::HtmlScript, Format::Json),
fenced(EmbedType::HtmlScriptJson)
);
assert_eq!(
embed_carrier(EmbedStyle::HtmlCode, Format::Toml),
fenced(EmbedType::HtmlCodeToml)
);
assert_eq!(
embed_carrier(EmbedStyle::Separate, Format::Yaml),
Some(MetaCarrier::WholeFile(Format::Yaml))
);
assert_eq!(
embed_carrier(EmbedStyle::Separate, Format::Fig),
Some(MetaCarrier::WholeFile(Format::Fig))
);
}
#[test]
fn no_frontmatter_is_all_body() {
let doc = Document::parse("note.md", "# Just a note\n").unwrap();
assert!(doc.meta.is_null());
assert_eq!(doc.body, "# Just a note\n");
assert_eq!(doc.carrier, None);
assert!(!doc.has_meta());
}
#[test]
fn unterminated_fence_is_not_frontmatter() {
let text = "---\ntitle: oops\nno closing fence\n";
let doc = Document::parse("x.md", text).unwrap();
assert!(doc.meta.is_null());
assert_eq!(doc.body, text);
assert_eq!(doc.carrier, None);
}
#[cfg(feature = "yaml")]
#[test]
fn split_borrows_yaml_frontmatter_and_body_without_parsing() {
let text = "---\ntitle: Root\n---\n# Body\n\nhello\n";
let (carrier, meta, before, after) = Document::split(text).unwrap();
assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterYaml));
assert_eq!(meta, "title: Root\n");
assert_eq!(before, "", "frontmatter has no host text above it");
assert_eq!(after, "# Body\n\nhello\n");
let body = after;
let doc = Document::parse("x.md", text).unwrap();
assert_eq!(doc.body, body);
}
#[cfg(feature = "yaml")]
#[test]
fn split_handles_crlf_line_endings() {
let text = "---\r\ntitle: Root\r\n---\r\nbody\r\n";
let (carrier, meta, before, after) = Document::split(text).unwrap();
assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterYaml));
assert_eq!(meta, "title: Root\r\n");
assert_eq!(before, "");
assert_eq!(after, "body\r\n");
}
#[test]
fn split_is_none_with_no_frontmatter() {
assert_eq!(Document::split("# Just a note\n"), None);
}
#[test]
fn split_is_none_for_an_unterminated_fence() {
let text = "---\ntitle: oops\nno closing fence\n";
assert_eq!(Document::split(text), None);
}
#[cfg(feature = "fig-lang")]
#[test]
fn split_recognizes_a_non_yaml_carrier() {
let text = "```fig\ntitle = prov\n```\n# Body\n";
let (carrier, meta, before, after) = Document::split(text).unwrap();
assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterFig));
assert_eq!(meta, "title = prov\n");
assert_eq!(before, "");
assert_eq!(after, "# Body\n");
}
#[cfg(feature = "json")]
#[test]
fn split_recognizes_json_frontmatter() {
let text = ";;;\n{\"title\": \"Root\"}\n;;;\nbody\n";
let (carrier, meta, before, after) = Document::split(text).unwrap();
assert_eq!(carrier, MetaCarrier::Fenced(EmbedType::FrontmatterJson));
assert_eq!(meta, "{\"title\": \"Root\"}\n");
assert_eq!(before, "");
assert_eq!(after, "body\n");
}
#[cfg(feature = "yaml")]
#[test]
fn a_mid_document_island_keeps_the_host_text_on_both_sides() {
let text = concat!(
"<!doctype html>\n",
"<html><head><title>KEEP ME</title></head>\n",
"<body>\n",
"<script type=\"application/yaml\">\n",
"title: Mid\n",
"</script>\n",
"<p>tail</p>\n",
"</html>\n",
);
let doc = Document::parse("page.html", text).unwrap();
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Mid"));
assert!(
doc.body.contains("KEEP ME"),
"the head above the island was dropped: {:?}",
doc.body
);
assert!(doc.body.contains("<p>tail</p>"), "the tail was dropped");
let (_, meta, before, after) = Document::split(text).unwrap();
assert_eq!(doc.body, format!("{before}{after}"));
assert!(
!doc.body.contains("<script"),
"the island leaked into the body"
);
assert_eq!(meta, "title: Mid\n");
}
#[cfg(feature = "yaml")]
#[test]
fn crlf_fences_are_handled() {
let text = "---\r\ntitle: Root\r\n---\r\nbody\r\n";
let doc = Document::parse("x.md", text).unwrap();
assert_eq!(
doc.carrier,
Some(MetaCarrier::Fenced(EmbedType::FrontmatterYaml))
);
assert_eq!(doc.body, "body\r\n");
assert_eq!(doc.meta.get("title").and_then(Value::as_str), Some("Root"));
}
}