use std::path::Path;
use crate::markdown::types::{MarkdownError, MarkdownFrontmatter, MarkdownPage, MarkdownSource};
use crate::static_gen::StaticParams;
pub struct MarkdownRegistry {
pages: indexmap::IndexMap<String, MarkdownPage>,
}
impl MarkdownRegistry {
pub fn from_embedded(sources: &[MarkdownSource]) -> Result<Self, MarkdownError> {
let mut pages = indexmap::IndexMap::new();
for source in sources {
let page = parse_page(source.slug.to_owned(), source.content)?;
if pages.insert(page.slug.clone(), page).is_some() {
return Err(MarkdownError::DuplicateSlug {
slug: source.slug.to_owned(),
});
}
}
pages.sort_by(|_, a, _, b| {
(a.frontmatter.order, a.slug.as_str()).cmp(&(b.frontmatter.order, b.slug.as_str()))
});
Ok(Self { pages })
}
pub fn from_dir(dir: &Path) -> Result<Self, MarkdownError> {
let mut pages = indexmap::IndexMap::new();
let read_dir = std::fs::read_dir(dir).map_err(|source| MarkdownError::Io {
path: dir.to_owned(),
source,
})?;
for entry in read_dir {
let entry = entry.map_err(|source| MarkdownError::Io {
path: dir.to_owned(),
source,
})?;
if !entry.file_type().is_ok_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let slug = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| MarkdownError::InvalidFileName {
name: path.display().to_string(),
})?
.to_owned();
let content = std::fs::read_to_string(&path).map_err(|source| MarkdownError::Io {
path: path.clone(),
source,
})?;
let page = parse_page(slug.clone(), &content)?;
if pages.insert(slug.clone(), page).is_some() {
return Err(MarkdownError::DuplicateSlug { slug });
}
}
pages.sort_by(|_, a, _, b| {
(a.frontmatter.order, a.slug.as_str()).cmp(&(b.frontmatter.order, b.slug.as_str()))
});
Ok(Self { pages })
}
#[must_use]
pub fn get(&self, slug: &str) -> Option<&MarkdownPage> {
self.pages.get(slug)
}
#[must_use]
pub fn all_sorted(&self) -> Vec<&MarkdownPage> {
self.pages.values().collect()
}
#[must_use]
pub fn static_params(&self) -> Vec<StaticParams> {
self.all_sorted()
.into_iter()
.map(|p| {
let mut params = StaticParams::new();
params.insert("slug".to_owned(), p.slug.clone());
params
})
.collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.pages.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.pages.is_empty()
}
}
fn parse_page(slug: String, content: &str) -> Result<MarkdownPage, MarkdownError> {
let (frontmatter, body) = split_frontmatter(&slug, content)?;
Ok(MarkdownPage {
slug,
frontmatter,
body: body.trim_start().to_owned(),
})
}
fn split_frontmatter<'a>(
slug: &str,
content: &'a str,
) -> Result<(MarkdownFrontmatter, &'a str), MarkdownError> {
let content = content.trim_start();
let after_open = content
.strip_prefix("+++\n")
.or_else(|| content.strip_prefix("+++\r\n"))
.ok_or_else(|| MarkdownError::FrontmatterMissing {
slug: slug.to_owned(),
})?;
let close_pos = after_open
.find("\n+++")
.ok_or_else(|| MarkdownError::FrontmatterMissing {
slug: slug.to_owned(),
})?;
let toml_str = &after_open[..close_pos];
let after_close = &after_open[close_pos + 4..];
let body = after_close
.strip_prefix("\r\n")
.or_else(|| after_close.strip_prefix('\n'))
.unwrap_or(after_close);
let toml_normalized;
let toml_for_parse: &str = if toml_str.contains('\r') {
toml_normalized = toml_str.replace('\r', "");
&toml_normalized
} else {
toml_str
};
let frontmatter: MarkdownFrontmatter =
toml::from_str(toml_for_parse).map_err(|source| MarkdownError::FrontmatterInvalid {
slug: slug.to_owned(),
source,
})?;
Ok((frontmatter, body))
}
#[cfg(test)]
mod tests {
use super::*;
const GETTING_STARTED: MarkdownSource = MarkdownSource {
slug: "getting-started",
content: "+++\ntitle = \"Getting Started\"\ndescription = \"How to get started\"\norder = 1\n+++\n\n# Getting Started\n\nWelcome!\n",
};
const API_REFERENCE: MarkdownSource = MarkdownSource {
slug: "api-reference",
content: "+++\ntitle = \"API Reference\"\ndescription = \"API docs\"\norder = 2\n+++\n\n# API Reference\n\nRoutes and types.\n",
};
#[test]
fn parses_frontmatter_correctly() {
let page = parse_page(
"test".to_owned(),
"+++\ntitle = \"My Title\"\ndescription = \"A desc\"\norder = 3\n+++\n\n# Body\n",
)
.unwrap();
assert_eq!(page.frontmatter.title, "My Title");
assert_eq!(page.frontmatter.description, "A desc");
assert_eq!(page.frontmatter.order, 3);
assert_eq!(page.body, "# Body\n");
}
#[test]
fn description_defaults_to_empty_string() {
let page = parse_page(
"no-desc".to_owned(),
"+++\ntitle = \"No Desc\"\norder = 5\n+++\n\n# Body\n",
)
.unwrap();
assert_eq!(page.frontmatter.description, "");
}
#[test]
fn order_defaults_to_zero() {
let page = parse_page(
"no-order".to_owned(),
"+++\ntitle = \"No Order\"\n+++\n\n# Body\n",
)
.unwrap();
assert_eq!(page.frontmatter.order, 0);
}
#[test]
fn parses_crlf_frontmatter() {
let page = parse_page(
"crlf".to_owned(),
"+++\r\ntitle = \"CRLF Page\"\r\norder = 7\r\n+++\r\n\r\n# Body\r\n",
)
.unwrap();
assert_eq!(page.frontmatter.title, "CRLF Page");
assert_eq!(page.frontmatter.order, 7);
}
#[test]
fn missing_opening_delimiter_returns_error() {
let result = parse_page("bad".to_owned(), "# No frontmatter\n");
assert!(matches!(
result,
Err(MarkdownError::FrontmatterMissing { .. })
));
}
#[test]
fn missing_closing_delimiter_returns_error() {
let result = parse_page("bad".to_owned(), "+++\ntitle = \"Test\"\n# No closing\n");
assert!(matches!(
result,
Err(MarkdownError::FrontmatterMissing { .. })
));
}
#[test]
fn invalid_toml_returns_error() {
let result = parse_page(
"bad".to_owned(),
"+++\nnot valid toml = [[[\n+++\n\n# Body\n",
);
assert!(matches!(
result,
Err(MarkdownError::FrontmatterInvalid { .. })
));
}
#[test]
fn body_leading_whitespace_stripped() {
let page =
parse_page("test".to_owned(), "+++\ntitle = \"T\"\n+++\n\n\n\n# Body\n").unwrap();
assert!(page.body.starts_with("# Body"));
}
#[test]
fn builds_registry_from_embedded() {
let registry = MarkdownRegistry::from_embedded(&[GETTING_STARTED, API_REFERENCE]).unwrap();
assert_eq!(registry.len(), 2);
assert!(registry.get("getting-started").is_some());
assert!(registry.get("api-reference").is_some());
}
#[test]
fn get_returns_correct_page() {
let registry = MarkdownRegistry::from_embedded(&[GETTING_STARTED]).unwrap();
let page = registry.get("getting-started").unwrap();
assert_eq!(page.frontmatter.title, "Getting Started");
assert_eq!(page.frontmatter.order, 1);
assert_eq!(page.slug, "getting-started");
}
#[test]
fn get_unknown_slug_returns_none() {
let registry = MarkdownRegistry::from_embedded(&[GETTING_STARTED]).unwrap();
assert!(registry.get("nonexistent").is_none());
}
#[test]
fn all_sorted_orders_by_order_field() {
let registry = MarkdownRegistry::from_embedded(&[API_REFERENCE, GETTING_STARTED]).unwrap();
let pages = registry.all_sorted();
assert_eq!(pages[0].slug, "getting-started"); assert_eq!(pages[1].slug, "api-reference"); }
#[test]
fn all_sorted_tiebreaks_by_slug() {
let a = MarkdownSource {
slug: "zebra",
content: "+++\ntitle = \"Zebra\"\norder = 1\n+++\n\n# Z\n",
};
let b = MarkdownSource {
slug: "apple",
content: "+++\ntitle = \"Apple\"\norder = 1\n+++\n\n# A\n",
};
let registry = MarkdownRegistry::from_embedded(&[a, b]).unwrap();
let pages = registry.all_sorted();
assert_eq!(pages[0].slug, "apple");
assert_eq!(pages[1].slug, "zebra");
}
#[test]
fn static_params_returns_one_entry_per_page() {
let registry = MarkdownRegistry::from_embedded(&[GETTING_STARTED, API_REFERENCE]).unwrap();
let params = registry.static_params();
assert_eq!(params.len(), 2);
}
#[test]
fn static_params_uses_slug_key() {
let registry = MarkdownRegistry::from_embedded(&[GETTING_STARTED]).unwrap();
let params = registry.static_params();
assert_eq!(params[0].get("slug").unwrap(), "getting-started");
}
#[test]
fn static_params_sorted_by_order() {
let registry = MarkdownRegistry::from_embedded(&[API_REFERENCE, GETTING_STARTED]).unwrap();
let params = registry.static_params();
assert_eq!(params[0].get("slug").unwrap(), "getting-started");
assert_eq!(params[1].get("slug").unwrap(), "api-reference");
}
#[test]
fn empty_registry_is_empty() {
let registry = MarkdownRegistry::from_embedded(&[]).unwrap();
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(registry.all_sorted().is_empty());
assert!(registry.static_params().is_empty());
}
#[test]
fn missing_frontmatter_propagates_error_from_embedded() {
let bad = MarkdownSource {
slug: "bad",
content: "# No frontmatter\n",
};
let result = MarkdownRegistry::from_embedded(&[bad]);
assert!(matches!(
result,
Err(MarkdownError::FrontmatterMissing { .. })
));
}
#[test]
fn duplicate_slug_returns_error_from_embedded() {
let dup = MarkdownSource {
slug: "getting-started",
content: "+++\ntitle = \"Dup\"\norder = 99\n+++\n\n# Dup\n",
};
let result = MarkdownRegistry::from_embedded(&[GETTING_STARTED, dup]);
assert!(matches!(result, Err(MarkdownError::DuplicateSlug { .. })));
}
#[test]
fn loads_from_directory() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("page-one.md"),
"+++\ntitle = \"Page One\"\norder = 1\n+++\n\n# Page One\n",
)
.unwrap();
std::fs::write(
dir.path().join("page-two.md"),
"+++\ntitle = \"Page Two\"\norder = 2\n+++\n\n# Page Two\n",
)
.unwrap();
std::fs::write(dir.path().join("README.txt"), "ignore me").unwrap();
let registry = MarkdownRegistry::from_dir(dir.path()).unwrap();
assert_eq!(registry.len(), 2);
assert!(registry.get("page-one").is_some());
assert!(registry.get("page-two").is_some());
}
#[test]
fn directory_not_found_returns_io_error() {
let result = MarkdownRegistry::from_dir(std::path::Path::new(
"/nonexistent/path/that/does/not/exist",
));
assert!(matches!(result, Err(MarkdownError::Io { .. })));
}
#[test]
fn from_dir_skips_subdirectory_entries() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("real-page.md"),
"+++\ntitle = \"Real\"\norder = 1\n+++\n\n# Real\n",
)
.unwrap();
std::fs::create_dir(dir.path().join("subdir.md")).unwrap();
let registry = MarkdownRegistry::from_dir(dir.path()).unwrap();
assert_eq!(registry.len(), 1);
assert!(registry.get("real-page").is_some());
}
#[test]
fn from_dir_duplicate_slug_returns_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("page.md"),
"+++\ntitle = \"Page A\"\norder = 1\n+++\n\n# A\n",
)
.unwrap();
let dup = MarkdownSource {
slug: "page",
content: "+++\ntitle = \"Page B\"\norder = 2\n+++\n\n# B\n",
};
let base = MarkdownSource {
slug: "page",
content: "+++\ntitle = \"Page A\"\norder = 1\n+++\n\n# A\n",
};
let result = MarkdownRegistry::from_embedded(&[base, dup]);
assert!(matches!(result, Err(MarkdownError::DuplicateSlug { .. })));
let registry = MarkdownRegistry::from_dir(dir.path()).unwrap();
assert_eq!(registry.len(), 1);
}
#[tokio::test]
async fn integration_statically_renders_two_markdown_pages() {
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::markdown::RenderOptions;
use crate::markdown::render;
use crate::static_gen::{StaticRouteMeta, render_static_routes};
fn doc_params(_: axum::Router) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async {
vec![
crate::static_params! { "slug" => "getting-started" },
crate::static_params! { "slug" => "api-reference" },
]
})
}
let registry =
Arc::new(MarkdownRegistry::from_embedded(&[GETTING_STARTED, API_REFERENCE]).unwrap());
let reg = registry.clone();
let router = axum::Router::new().route(
"/docs/{slug}",
axum::routing::get({
let r = reg.clone();
move |axum::extract::Path(slug): axum::extract::Path<String>| {
let r = r.clone();
async move {
r.get(&slug).map_or_else(
|| (axum::http::StatusCode::NOT_FOUND, "not found".to_owned()),
|page| {
let rendered = render(&page.body, RenderOptions::default());
(axum::http::StatusCode::OK, rendered.html)
},
)
}
}
}),
);
let meta = StaticRouteMeta {
path: "/docs/{slug}",
name: "test_show_doc",
revalidate: None,
params_fn: Some(doc_params),
};
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(router, &[meta], &dist).await.unwrap();
let html_a = std::fs::read_to_string(dist.join("docs/getting-started/index.html")).unwrap();
assert!(html_a.contains("Getting Started"));
let html_b = std::fs::read_to_string(dist.join("docs/api-reference/index.html")).unwrap();
assert!(html_b.contains("API Reference"));
}
}