use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentFormat {
Markdown,
Djot,
Html,
}
impl ContentFormat {
pub fn from_extension(path: &Path) -> Option<Self> {
match path.extension()?.to_str()? {
"md" | "markdown" => Some(Self::Markdown),
"dj" | "djot" => Some(Self::Djot),
"html" | "htm" => Some(Self::Html),
_ => None,
}
}
fn twig_format(self) -> twig::Format {
match self {
Self::Markdown => twig::Format::Markdown,
Self::Djot => twig::Format::Djot,
Self::Html => twig::Format::Html,
}
}
pub fn extension(self) -> &'static str {
match self {
Self::Markdown => "md",
Self::Djot => "dj",
Self::Html => "html",
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Markdown => "markdown",
Self::Djot => "djot",
Self::Html => "html",
}
}
pub fn from_config_str(value: &str) -> Option<Self> {
match value {
"markdown" | "md" => Some(Self::Markdown),
"djot" | "dj" => Some(Self::Djot),
"html" | "htm" => Some(Self::Html),
_ => None,
}
}
}
fn parse(body: &str, format: ContentFormat) -> crate::error::Result<twig::Document> {
twig::Document::parse_str(body, format.twig_format())
.map_err(|e| crate::error::Error::Content(format!("twig parse: {e}")))
}
pub fn render_html(body: &str, format: ContentFormat) -> crate::error::Result<String> {
let mut doc = parse(body, format)?;
let html = doc
.render_html()
.map_err(|e| crate::error::Error::Content(format!("twig render: {e}")))?;
String::from_utf8(html)
.map_err(|e| crate::error::Error::Content(format!("twig produced non-UTF-8 HTML: {e}")))
}
const CODE_KINDS: [&str; 4] = ["verbatim", "code_block", "raw_inline", "raw_block"];
pub fn code_spans(
body: &str,
format: ContentFormat,
) -> crate::error::Result<Vec<std::ops::Range<usize>>> {
let mut doc = parse(body, format)?;
let mut spans = Vec::new();
for kind in CODE_KINDS {
let matches = doc
.query(kind)
.map_err(|e| crate::error::Error::Content(format!("twig query {kind}: {e}")))?;
spans.extend(matches.into_iter().map(|m| m.span));
}
spans.sort_by_key(|s| s.start);
Ok(spans)
}
pub fn link_spans(
body: &str,
format: ContentFormat,
) -> crate::error::Result<Vec<std::ops::Range<usize>>> {
let mut doc = parse(body, format)?;
let mut spans: Vec<_> = doc
.query("link")
.map_err(|e| crate::error::Error::Content(format!("twig query link: {e}")))?
.into_iter()
.map(|m| m.span)
.collect();
spans.sort_by_key(|s| s.start);
Ok(spans)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn infers_format_from_extension() {
assert_eq!(
ContentFormat::from_extension(Path::new("a.md")),
Some(ContentFormat::Markdown)
);
assert_eq!(
ContentFormat::from_extension(Path::new("a.markdown")),
Some(ContentFormat::Markdown)
);
assert_eq!(
ContentFormat::from_extension(Path::new("a.dj")),
Some(ContentFormat::Djot)
);
assert_eq!(
ContentFormat::from_extension(Path::new("a.djot")),
Some(ContentFormat::Djot)
);
assert_eq!(
ContentFormat::from_extension(Path::new("a.html")),
Some(ContentFormat::Html)
);
assert_eq!(
ContentFormat::from_extension(Path::new("a.htm")),
Some(ContentFormat::Html)
);
assert_eq!(ContentFormat::from_extension(Path::new("a.yaml")), None);
assert_eq!(ContentFormat::from_extension(Path::new("noext")), None);
}
#[test]
fn extension_matches_the_primary_from_extension_spelling() {
for f in [
ContentFormat::Markdown,
ContentFormat::Djot,
ContentFormat::Html,
] {
let name = format!("derived.{}", f.extension());
assert_eq!(ContentFormat::from_extension(Path::new(&name)), Some(f));
}
}
#[test]
fn renders_markdown_to_html_via_twig_ffi() {
let html = render_html("# hi\n", ContentFormat::Markdown).unwrap();
assert_eq!(html, "<h1>hi</h1>\n");
}
#[test]
fn renders_djot_to_html_via_twig_ffi() {
let html = render_html("_hi_\n", ContentFormat::Djot).unwrap();
assert_eq!(html, "<p><em>hi</em></p>\n");
}
#[test]
fn renders_html_via_twig_ffi() {
let html = render_html("<p>hi</p>", ContentFormat::Html).unwrap();
assert!(html.contains("hi"));
}
#[test]
fn link_spans_find_inline_links_but_not_code_or_prose() {
let body = "See [the doc](notes/a.md) and `[not](a link)` and plain [text].";
let spans = link_spans(body, ContentFormat::Markdown).unwrap();
let want = body.find("[the doc](notes/a.md)").unwrap();
assert!(
spans
.iter()
.any(|s| s.start == want && &body[s.clone()] == "[the doc](notes/a.md)"),
"expected the inline link span, got {spans:?}"
);
let code_at = body.find("[not]").unwrap();
assert!(
!spans.iter().any(|s| s.contains(&code_at)),
"code must not be a link: {spans:?}"
);
let bracket_at = body.find("[text]").unwrap();
assert!(
!spans.iter().any(|s| s.contains(&bracket_at)),
"bare brackets are not a link"
);
}
#[test]
fn link_spans_read_djot_links_too() {
let body = "Here is [a link](../b.dj) inline.\n";
let spans = link_spans(body, ContentFormat::Djot).unwrap();
assert!(
spans
.iter()
.any(|s| &body[s.clone()] == "[a link](../b.dj)"),
"djot inline link span, got {spans:?}"
);
}
#[test]
fn code_spans_cover_verbatim_but_not_prose() {
let body = "See [[colophon:abc123]] and `[[inf] * n for _ in range(m)]]` here.";
let spans = code_spans(body, ContentFormat::Markdown).unwrap();
let wikilink_span = body.find("[[colophon:abc123]]").unwrap()
..body.find("[[colophon:abc123]]").unwrap() + "[[colophon:abc123]]".len();
assert!(
!spans
.iter()
.any(|cs| cs.start < wikilink_span.end && wikilink_span.start < cs.end)
);
let code_start = body.find('`').unwrap();
let code_end = body.rfind('`').unwrap() + 1;
assert!(
spans
.iter()
.any(|cs| cs.start <= code_start && code_end <= cs.end)
);
}
}