use std::{fs, io, ops::Range, path::Path};
use serde::de::DeserializeOwned;
use crate::{
DefaultFrontmatter, Error, Result, TitleFrontmatter,
markdown::{render_markdown, title_from_markdown},
util::AbsPagePath,
};
#[derive(Debug)]
pub struct FlatPage<F = DefaultFrontmatter> {
content: String,
body_range: Range<usize>,
pub frontmatter: F,
}
impl<F: DeserializeOwned> FlatPage<F> {
pub fn by_url(root: impl AsRef<Path>, url: &str) -> Result<Option<Self>> {
let Some(path) = AbsPagePath::from_raw_url(root.as_ref(), url) else {
return Ok(None);
};
Self::by_path(&path)
}
pub fn by_path(path: impl AsRef<Path>) -> Result<Option<Self>> {
let path = path.as_ref();
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(Error::read_file(e, path)),
};
Self::from_owned_content(content)
.map(Some)
.map_err(|e| Error::parse_frontmatter(e, path))
}
pub fn body(&self) -> &str {
&self.content[self.body_range.start..self.body_range.end]
}
pub fn html(&self) -> String {
render_markdown(self.body())
}
#[cfg(test)]
fn from_content(content: &str) -> std::result::Result<Self, markdown_frontmatter::Error> {
Self::from_owned_content(content.to_string())
}
fn from_owned_content(
content: String,
) -> std::result::Result<Self, markdown_frontmatter::Error> {
let (frontmatter, body) = markdown_frontmatter::parse::<F>(&content)?;
let body_start = body.as_ptr() as usize - content.as_ptr() as usize;
let body_end = body_start + body.len();
debug_assert!(body_end <= content.len());
Ok(Self {
content,
body_range: body_start..body_end,
frontmatter,
})
}
}
impl<F: TitleFrontmatter> FlatPage<F> {
pub fn title(&self) -> &str {
self.frontmatter
.title()
.unwrap_or_else(|| title_from_markdown(self.body()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
DefaultFrontmatter, Error,
test_helpers::{TestDir, write_page},
};
type DefaultPage = FlatPage<DefaultFrontmatter>;
#[cfg(feature = "toml")]
#[derive(Debug, serde::Deserialize)]
struct OptionalTitleFrontmatter {
title: Option<String>,
}
#[cfg(feature = "toml")]
impl crate::TitleFrontmatter for OptionalTitleFrontmatter {
fn title(&self) -> Option<&str> {
self.title.as_deref()
}
}
fn assert_parse_frontmatter_error(content: &str) {
let root = TestDir::new();
let path = root.path().join("broken.md");
write_page(root.path(), "broken.md", content);
assert!(
matches!(DefaultPage::by_path(&path), Err(Error::ParseFrontmatter { path: error_path, .. }) if error_path == path)
);
}
#[test]
fn flatpage_title() {
let page = DefaultPage::from_content("# Foo").unwrap();
assert_eq!(page.title(), "Foo");
#[cfg(feature = "toml")]
assert_eq!(
DefaultPage::from_content("+++\ntitle = \"Bar\"\n+++\n# Foo")
.unwrap()
.title(),
"Bar"
);
}
#[cfg(feature = "toml")]
#[test]
fn typed_frontmatter_uses_body_title_without_frontmatter() {
let page = FlatPage::<OptionalTitleFrontmatter>::from_content("# Foo").unwrap();
assert_eq!(page.title(), "Foo");
}
#[cfg(feature = "toml")]
#[test]
fn typed_frontmatter_uses_body_title_when_frontmatter_title_is_missing() {
let page = FlatPage::<OptionalTitleFrontmatter>::from_content("+++\n+++\n# Foo").unwrap();
assert_eq!(page.title(), "Foo");
}
#[cfg(feature = "toml")]
#[test]
fn typed_frontmatter_can_require_description() {
#[derive(Debug, serde::Deserialize)]
struct Frontmatter {
description: String,
}
impl crate::TitleFrontmatter for Frontmatter {}
assert!(FlatPage::<Frontmatter>::from_content("# Foo").is_err());
let page = FlatPage::<Frontmatter>::from_content("+++\ndescription = \"Bar\"\n+++\n# Foo")
.unwrap();
assert_eq!(page.title(), "Foo");
assert_eq!(page.frontmatter.description, "Bar");
}
#[cfg(feature = "toml")]
#[test]
fn typed_frontmatter_can_override_title() {
#[derive(Debug, serde::Deserialize)]
struct Frontmatter {
title: String,
}
impl crate::TitleFrontmatter for Frontmatter {
fn title(&self) -> Option<&str> {
Some(&self.title)
}
}
let page =
FlatPage::<Frontmatter>::from_content("+++\ntitle = \"Bar\"\n+++\n# Foo").unwrap();
assert_eq!(page.title(), "Bar");
}
#[cfg(feature = "toml")]
#[test]
fn flatpage_without_frontmatter_trait_still_parses() {
#[derive(Debug, serde::Deserialize)]
struct Frontmatter {
description: String,
}
let page = FlatPage::<Frontmatter>::from_content("+++\ndescription = \"Bar\"\n+++\n# Foo")
.unwrap();
assert_eq!(page.frontmatter.description, "Bar");
}
#[test]
fn markdown_rendering() {
let page = DefaultPage::from_content("# Foo\nBar").unwrap();
assert_eq!(page.html(), "<h1>Foo</h1>\n<p>Bar</p>\n");
#[cfg(feature = "toml")]
{
let page = DefaultPage::from_content("+++\ndescription = \"Bar\"\n+++\n# Foo").unwrap();
assert_eq!(page.html(), "<h1>Foo</h1>\n");
let page =
DefaultPage::from_content("+++\ntitle = \"Foo\"\ndescription = \"Bar\"\n+++")
.unwrap();
assert_eq!(page.html(), "");
}
}
#[test]
fn flatpage_by_url_reads_nested_paths() {
let root = TestDir::new();
write_page(root.path(), "guides/rust/index.md", "# Rust Guide");
write_page(root.path(), "guides/install.md", "# Install");
write_page(root.path(), "guides/v1.2.md", "# Versioned Guide");
let index = DefaultPage::by_url(root.path(), "/guides/rust/")
.unwrap()
.unwrap();
assert_eq!(index.title(), "Rust Guide");
let page = DefaultPage::by_url(root.path(), "/guides/install")
.unwrap()
.unwrap();
assert_eq!(page.title(), "Install");
let dotted = DefaultPage::by_url(root.path(), "/guides/v1.2")
.unwrap()
.unwrap();
assert_eq!(dotted.title(), "Versioned Guide");
assert!(
DefaultPage::by_url(root.path(), "guides/install")
.unwrap()
.is_none()
);
}
#[test]
fn flatpage_by_path_returns_none_for_missing_file() {
let root = TestDir::new();
let path = root.path().join("missing.md");
assert!(DefaultPage::by_path(&path).unwrap().is_none());
}
#[test]
fn flatpage_by_path_reports_read_file_error() {
let root = TestDir::new();
let path = root.path().join("guides");
std::fs::create_dir(&path).unwrap();
assert!(
matches!(DefaultPage::by_path(&path), Err(Error::ReadFile { path: error_path, .. }) if error_path == path)
);
}
#[cfg(feature = "json")]
#[test]
fn flatpage_by_path_reports_json_frontmatter_error() {
assert_parse_frontmatter_error("{\n \"title\": \n}\n# Foo");
}
#[cfg(feature = "toml")]
#[test]
fn flatpage_by_path_reports_toml_frontmatter_error() {
assert_parse_frontmatter_error("+++\ntitle = \n+++\n# Foo");
}
#[cfg(feature = "yaml")]
#[test]
fn flatpage_by_path_reports_yaml_frontmatter_error() {
assert_parse_frontmatter_error("---\ntitle: [\n---\n# Foo");
}
#[cfg(feature = "json")]
#[test]
fn json_frontmatter() {
let page = DefaultPage::from_content("{\n \"title\": \"Foo\"\n}\n# Bar").unwrap();
assert_eq!(page.title(), "Foo");
assert_eq!(page.body(), "# Bar");
}
#[cfg(feature = "toml")]
#[test]
fn toml_frontmatter() {
let page = DefaultPage::from_content("+++\ntitle = \"Foo\"\n+++\n# Bar").unwrap();
assert_eq!(page.title(), "Foo");
assert_eq!(page.body(), "# Bar");
}
}