dioxus-docs-kit 0.7.0

Reusable documentation site shell for Dioxus applications
Documentation
use std::collections::{HashMap, HashSet};

use super::types::{BlogCategory, BlogCategoryMetadata};
use crate::DocsKitError;

pub(super) fn build_categories(
    tags: &[String],
    metadata: &HashMap<String, BlogCategoryMetadata>,
) -> Result<Vec<BlogCategory>, DocsKitError> {
    let mut slugs = HashSet::new();
    tags.iter().map(|tag| {
        let data = metadata.get(tag).cloned().unwrap_or_default();
        let slug = data.slug.unwrap_or_else(|| dioxus_mdx::slugify(tag));
        if slug.is_empty() || !slug.chars().all(|c| c.is_alphanumeric() || c == '-') {
            return Err(DocsKitError::BlogConfig(format!(
                "tag {tag:?} needs a non-empty category slug containing only letters, numbers or hyphens"
            )));
        }
        if !slugs.insert(slug.clone()) {
            return Err(DocsKitError::BlogConfig(format!(
                "duplicate category slug {slug:?}; set distinct slugs in _blog.json categories"
            )));
        }
        let title = data.title.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| tag.clone());
        let description = data.description.unwrap_or_else(|| format!("Browse articles about {title}."));
        Ok(BlogCategory { tag: tag.clone(), slug, title, description, image: data.image })
    }).collect()
}

pub(super) fn validate_base_path(path: &str) -> Result<(), DocsKitError> {
    if !path.starts_with('/')
        || path.starts_with("//")
        || path[1..]
            .split('/')
            .any(|part| part.is_empty() || part == "." || part == "..")
        || path
            .chars()
            .any(|c| c.is_whitespace() || matches!(c, '?' | '#' | '\\' | '%' | ':'))
    {
        return Err(DocsKitError::BlogConfig(format!(
            "category base path {path:?} must be a root-relative path such as /blog/categories"
        )));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn colliding_and_empty_slugs_require_overrides() {
        let tags = vec!["C++".into(), "C#".into()];
        assert!(build_categories(&tags, &HashMap::new()).is_err());
        assert!(build_categories(&["!!!".into()], &HashMap::new()).is_err());
        let metadata = HashMap::from([(
            "C++".into(),
            BlogCategoryMetadata {
                slug: Some("cpp".into()),
                ..Default::default()
            },
        )]);
        let categories = build_categories(&tags, &metadata).unwrap();
        assert_eq!(categories[0].slug, "cpp");
        assert_eq!(categories[1].slug, "c");
        let invalid = HashMap::from([(
            "C++".into(),
            BlogCategoryMetadata {
                slug: Some("bad/slug".into()),
                ..Default::default()
            },
        )]);
        assert!(build_categories(&tags, &invalid).is_err());
    }

    #[test]
    fn category_paths_must_be_root_relative_and_unambiguous() {
        for path in [
            "", "/", "relative", "//host", "/a//b", "/a/../b", "/a?b", "/a#b", "/a%20b", "/a b",
        ] {
            assert!(validate_base_path(path).is_err(), "{path}");
        }
        assert!(validate_base_path("/blog/categories").is_ok());
        assert!(validate_base_path("/topics").is_ok());
    }
}