flatpage 2.0.0

A simple filesystem-based Markdown page loader
Documentation
//! Page parsing and filesystem loading.

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,
};

/// Flat page with a fully typed frontmatter schema.
#[derive(Debug)]
pub struct FlatPage<F = DefaultFrontmatter> {
    content: String,
    body_range: Range<usize>,
    /// Fully typed frontmatter
    pub frontmatter: F,
}

impl<F: DeserializeOwned> FlatPage<F> {
    /// Returns a page by its URL.
    ///
    /// Trailing slashes are significant: `/foo` looks up `foo.md`, while
    /// `/foo/` looks up `foo/index.md`.
    ///
    /// Returns `Ok(None)` for invalid URLs and missing pages. Returns `Err` for
    /// I/O failures and frontmatter parsing errors.
    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)
    }

    /// Returns a page by its file path.
    ///
    /// Returns `Ok(None)` when the file does not exist.
    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))
    }

    /// Raw markdown version of the body.
    pub fn body(&self) -> &str {
        &self.content[self.body_range.start..self.body_range.end]
    }

    /// [`FlatPage::body`] rendered to HTML
    pub fn html(&self) -> String {
        render_markdown(self.body())
    }

    /// Parses a page from text
    #[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> {
    /// Returns the page title from frontmatter or the first non-empty body
    /// line.
    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");
    }
}