use crate::assets::{Asset, RouteAssets};
use crate::content::{ContentSources, Entry};
use crate::errors::BuildError;
use crate::routing::{extract_params_from_raw_route, guess_if_route_is_endpoint};
use rustc_hash::FxHashMap;
use std::any::Any;
use std::path::{Path, PathBuf};
use lol_html::{RewriteStrSettings, element, rewrite_str};
pub enum RenderResult {
Text(String),
Raw(Vec<u8>),
Err(Box<dyn std::error::Error>),
}
impl<T> From<Result<T, Box<dyn std::error::Error>>> for RenderResult
where
T: Into<RenderResult>,
{
fn from(val: Result<T, Box<dyn std::error::Error>>) -> Self {
match val {
Ok(s) => s.into(),
Err(e) => RenderResult::Err(e),
}
}
}
impl From<RenderResult> for Result<RenderResult, Box<dyn std::error::Error>> {
fn from(val: RenderResult) -> Self {
match val {
RenderResult::Err(e) => Err(e),
_ => Ok(val),
}
}
}
impl From<String> for RenderResult {
fn from(val: String) -> Self {
RenderResult::Text(val)
}
}
impl From<&str> for RenderResult {
fn from(val: &str) -> Self {
RenderResult::Text(val.to_string())
}
}
impl From<Vec<u8>> for RenderResult {
fn from(val: Vec<u8>) -> Self {
RenderResult::Raw(val)
}
}
impl From<&[u8]> for RenderResult {
fn from(val: &[u8]) -> Self {
RenderResult::Raw(val.to_vec())
}
}
pub type Pages<Params = PageParams, Props = ()> = Vec<Page<Params, Props>>;
#[derive(Debug, Clone)]
pub struct Page<Params = PageParams, Props = ()>
where
Params: Into<PageParams>,
{
pub params: Params,
pub props: Props,
#[doc(hidden)]
pub _source_entry: Option<(String, String)>,
}
impl<Params, Props> Page<Params, Props>
where
Params: Into<PageParams>,
{
pub fn new(params: Params, props: Props) -> Self {
Self {
params,
props,
_source_entry: None,
}
}
}
impl<Params> Page<Params, ()>
where
Params: Into<PageParams>,
{
pub fn from_params(params: Params) -> Self {
Self {
params,
props: (),
_source_entry: None,
}
}
}
#[derive(Clone)]
pub struct PaginationPage<T> {
pub page: usize,
pub per_page: usize,
pub total_items: usize,
pub total_pages: usize,
pub has_next: bool,
pub has_prev: bool,
pub start_index: usize,
pub end_index: usize,
pub items: Vec<T>,
}
impl<T> PaginationPage<T> {
pub fn new(page: usize, per_page: usize, total_items: usize, page_items: Vec<T>) -> Self {
let total_pages = if total_items == 0 {
1
} else {
total_items.div_ceil(per_page)
};
let start_index = page * per_page;
let end_index = ((page + 1) * per_page).min(total_items);
Self {
page,
per_page,
total_items,
total_pages,
has_next: page < total_pages - 1,
has_prev: page > 0,
start_index,
end_index,
items: page_items,
}
}
}
impl<T> std::fmt::Debug for PaginationPage<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PaginationPage")
.field("page", &self.page)
.field("per_page", &self.per_page)
.field("total_items", &self.total_items)
.field("total_pages", &self.total_pages)
.field("has_next", &self.has_next)
.field("has_prev", &self.has_prev)
.field("start_index", &self.start_index)
.field("end_index", &self.end_index)
.field("items", &format!("[{} items]", self.items.len()))
.finish()
}
}
pub type PaginatedContentPage<T> = PaginationPage<Entry<T>>;
pub fn paginate<T, I, Params>(
items: I,
per_page: usize,
mut params_fn: impl FnMut(usize) -> Params,
) -> Pages<Params, PaginationPage<T>>
where
I: IntoIterator<Item = T>,
Params: Into<PageParams>,
T: Clone,
{
let items: Vec<T> = items.into_iter().collect();
if items.is_empty() {
return vec![];
}
let total_items = items.len();
let total_pages = total_items.div_ceil(per_page);
let mut routes = Vec::new();
for page in 0..total_pages {
let params = params_fn(page);
let start_index = page * per_page;
let end_index = ((page + 1) * per_page).min(total_items);
let page_items = items[start_index..end_index].to_vec();
let props = PaginationPage::new(page, per_page, total_items, page_items);
routes.push(Page::new(params, props));
}
routes
}
pub fn redirect(url: &str) -> RenderResult {
RenderResult::Text(format!(
r#"<meta http-equiv="refresh" content="0; url={}" />"#,
url
))
}
pub struct PageContext<'a> {
pub params: &'a dyn Any,
pub props: &'a dyn Any,
pub(crate) content: &'a ContentSources,
pub assets: &'a mut RouteAssets,
pub current_path: &'a String,
pub base_url: &'a Option<String>,
pub variant: Option<String>,
pub(crate) access_log:
std::rc::Rc<std::cell::RefCell<crate::content::tracked::ContentAccessLog>>,
}
impl<'a> PageContext<'a> {
pub fn from_static_route(
content: &'a ContentSources,
assets: &'a mut RouteAssets,
current_path: &'a String,
base_url: &'a Option<String>,
variant: Option<String>,
) -> Self {
Self {
params: &(),
props: &(),
content,
assets,
current_path,
base_url,
variant,
access_log: std::rc::Rc::new(std::cell::RefCell::new(
crate::content::tracked::ContentAccessLog::new(),
)),
}
}
pub fn from_dynamic_route(
dynamic_page: &'a PagesResult,
content: &'a ContentSources,
assets: &'a mut RouteAssets,
current_path: &'a String,
base_url: &'a Option<String>,
variant: Option<String>,
) -> Self {
Self {
params: dynamic_page.1.as_ref(),
props: dynamic_page.2.as_ref(),
content,
assets,
current_path,
base_url,
variant,
access_log: std::rc::Rc::new(std::cell::RefCell::new(
crate::content::tracked::ContentAccessLog::new(),
)),
}
}
pub fn content<T: 'static>(
&self,
name: &str,
) -> crate::content::tracked::TrackedContentSource<'a, T> {
crate::content::tracked::TrackedContentSource {
inner: self.content.get_source::<T>(name),
source_name: name.to_string(),
log: self.access_log.clone(),
}
}
pub(crate) fn take_access_log(&self) -> crate::content::tracked::ContentAccessLog {
self.access_log.take()
}
pub fn params<T: 'static + Clone>(&self) -> T {
self.params
.downcast_ref::<T>()
.unwrap_or_else(|| panic!("Params type mismatch: got {}", std::any::type_name::<T>()))
.clone()
}
pub fn props<T: 'static + Clone>(&self) -> T {
self.props
.downcast_ref::<T>()
.unwrap_or_else(|| panic!("Props type mismatch: got {}", std::any::type_name::<T>()))
.clone()
}
pub fn params_ref<T: 'static>(&self) -> &T {
self.params
.downcast_ref::<T>()
.unwrap_or_else(|| panic!("Params type mismatch: got {}", std::any::type_name::<T>()))
}
pub fn props_ref<T: 'static>(&self) -> &T {
self.props
.downcast_ref::<T>()
.unwrap_or_else(|| panic!("Props type mismatch: got {}", std::any::type_name::<T>()))
}
pub fn canonical_url(&self) -> Option<String> {
self.base_url
.as_ref()
.map(|base| format!("{}{}", base, self.current_path))
}
}
pub struct DynamicRouteContext<'a> {
pub(crate) content: &'a ContentSources,
pub assets: &'a mut RouteAssets,
pub variant: Option<&'a str>,
pub(crate) access_log:
std::rc::Rc<std::cell::RefCell<crate::content::tracked::ContentAccessLog>>,
}
impl<'a> DynamicRouteContext<'a> {
pub fn new(
content: &'a ContentSources,
assets: &'a mut RouteAssets,
variant: Option<&'a str>,
) -> Self {
Self {
content,
assets,
variant,
access_log: std::rc::Rc::new(std::cell::RefCell::new(
crate::content::tracked::ContentAccessLog::default(),
)),
}
}
pub fn content<T: 'static>(
&self,
name: &str,
) -> crate::content::tracked::TrackedContentSource<'a, T> {
crate::content::tracked::TrackedContentSource {
inner: self.content.get_source::<T>(name),
source_name: name.to_string(),
log: self.access_log.clone(),
}
}
#[allow(dead_code)]
pub(crate) fn take_access_log(&self) -> crate::content::tracked::ContentAccessLog {
self.access_log.take()
}
}
pub trait Route<Params = PageParams, Props = ()>
where
Params: Into<PageParams>,
Props: 'static,
{
fn pages(&self, _ctx: &mut DynamicRouteContext) -> Pages<Params, Props> {
Vec::new()
}
fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult>;
}
#[derive(Clone, Default, Debug)]
pub struct PageParams(pub FxHashMap<String, Option<String>>);
impl PageParams {
pub fn from_vec<T>(params: Vec<T>) -> Vec<PageParams>
where
T: Into<PageParams>,
{
params.into_iter().map(|p| p.into()).collect()
}
}
impl From<&PageParams> for PageParams {
fn from(params: &PageParams) -> Self {
params.clone()
}
}
impl<T> FromIterator<T> for PageParams
where
T: Into<PageParams>,
{
fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
let mut map = FxHashMap::default();
for item in iter {
let item = item.into();
map.extend(item.0);
}
PageParams(map)
}
}
#[derive(PartialEq, Eq, Debug)]
pub enum RouteType {
Static,
Dynamic,
}
#[doc(hidden)]
pub trait InternalRoute {
fn route_raw(&self) -> Option<String>;
fn variants(&self) -> Vec<(String, String)> {
vec![]
}
fn sitemap_metadata(&self) -> crate::sitemap::RouteSitemapMetadata {
crate::sitemap::RouteSitemapMetadata::default()
}
fn always_revalidate(&self) -> bool {
false
}
fn is_endpoint(&self) -> bool {
self.route_raw()
.as_ref()
.map(|path| guess_if_route_is_endpoint(path))
.unwrap_or(false)
}
fn url(&self, params: &PageParams) -> String {
let route = self.route_raw().unwrap_or_default();
let params_def = extract_params_from_raw_route(&route);
build_url_with_params(&route, ¶ms_def, params, self.is_endpoint())
}
fn variant_url(&self, params: &PageParams, variant: &str) -> Result<String, String> {
let variants = self.variants();
let variant_path = variants
.iter()
.find(|(id, _)| id == variant)
.map(|(_, path)| path.clone())
.ok_or_else(|| format!("Variant '{}' not found", variant))?;
let is_endpoint = guess_if_route_is_endpoint(&variant_path);
let params_def = extract_params_from_raw_route(&variant_path);
Ok(build_url_with_params(
&variant_path,
¶ms_def,
params,
is_endpoint,
))
}
fn file_path(&self, params: &PageParams, output_dir: &Path) -> PathBuf {
let url = self.url(params);
build_file_path_from_url(&url, output_dir, self.is_endpoint())
}
fn variant_file_path(
&self,
params: &PageParams,
output_dir: &Path,
variant: &str,
) -> Result<PathBuf, String> {
let url = self.variant_url(params, variant)?;
let variants = self.variants();
let variant_path = variants
.iter()
.find(|(id, _)| id == variant)
.map(|(_, path)| path.as_str())
.ok_or_else(|| format!("Variant '{}' not found", variant))?;
let is_endpoint = guess_if_route_is_endpoint(variant_path);
Ok(build_file_path_from_url(&url, output_dir, is_endpoint))
}
}
pub trait RouteExt<Params = PageParams, Props = ()>: Route<Params, Props> + InternalRoute
where
Params: Into<PageParams>,
Props: 'static,
{
fn url(&self, params: Params) -> String {
InternalRoute::url(self, ¶ms.into())
}
fn variant_url(&self, params: Params, variant: &str) -> Result<String, String> {
InternalRoute::variant_url(self, ¶ms.into(), variant)
}
}
impl<U, Params, Props> RouteExt<Params, Props> for U
where
U: Route<Params, Props> + InternalRoute,
Params: Into<PageParams>,
Props: 'static,
{
}
pub trait FullRoute: InternalRoute + Sync + Send {
#[doc(hidden)]
fn render_internal(
&self,
ctx: &mut PageContext,
) -> Result<RenderResult, Box<dyn std::error::Error>>;
#[doc(hidden)]
fn pages_internal(&self, context: &mut DynamicRouteContext) -> PagesResults;
fn get_pages(&self, context: &mut DynamicRouteContext) -> PagesResults {
self.pages_internal(context)
}
fn build(&self, ctx: &mut PageContext) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let result = self.render_internal(ctx)?;
let bytes = finish_route(result, ctx.assets, self.route_raw().unwrap_or_default())?;
Ok(bytes)
}
}
use crate::routing::ParameterDef;
use std::sync::OnceLock;
pub fn build_url_with_params(
route_template: &str,
params_def: &[ParameterDef],
params: &PageParams,
is_endpoint: bool,
) -> String {
if params_def.is_empty() {
let mut result = route_template.to_string();
if !result.starts_with('/') {
result.insert(0, '/');
}
if !is_endpoint && !result.ends_with('/') {
result.push('/');
}
return result;
}
let mut capacity = route_template.len();
for pd in params_def {
capacity -= pd.length;
if let Some(Some(v)) = params.0.get(&pd.key) {
capacity += v.len();
}
}
let mut result = String::with_capacity(capacity + 2); let mut pos = 0;
for pd in params_def.iter().rev() {
let segment = &route_template[pos..pd.index];
push_collapsing_slashes(&mut result, segment);
let value = params.0.get(&pd.key).unwrap_or_else(|| {
panic!(
"Route {:?} is missing parameter {:?}",
route_template, pd.key
)
});
if let Some(v) = value.as_deref() {
push_collapsing_slashes(&mut result, v);
}
pos = pd.index + pd.length;
}
push_collapsing_slashes(&mut result, &route_template[pos..]);
if !result.starts_with('/') {
result.insert(0, '/');
}
if !is_endpoint && !result.ends_with('/') {
result.push('/');
}
result
}
#[inline]
fn push_collapsing_slashes(result: &mut String, s: &str) {
if s.is_empty() {
return;
}
if result.ends_with('/') && s.starts_with('/') {
result.push_str(&s[1..]);
} else {
result.push_str(s);
}
}
pub fn build_file_path_from_url(url: &str, output_dir: &Path, is_endpoint: bool) -> PathBuf {
let dir = output_dir.to_str().expect("output_dir must be valid UTF-8");
let dir = dir.trim_end_matches('/');
let suffix = if is_endpoint { "" } else { "index.html" };
let capacity = dir.len() + url.len() + suffix.len();
let mut result = String::with_capacity(capacity);
result.push_str(dir);
result.push_str(url); result.push_str(suffix);
PathBuf::from(result)
}
pub struct CachedRoute<'a> {
inner: &'a dyn FullRoute,
params_cache: OnceLock<Vec<ParameterDef>>,
is_endpoint: OnceLock<bool>,
variant_caches: OnceLock<FxHashMap<String, (Vec<ParameterDef>, bool)>>,
}
impl<'a> CachedRoute<'a> {
pub fn new(route: &'a dyn FullRoute) -> Self {
Self {
inner: route,
params_cache: OnceLock::new(),
is_endpoint: OnceLock::new(),
variant_caches: OnceLock::new(),
}
}
fn get_cached_params(&self) -> &Vec<ParameterDef> {
self.params_cache.get_or_init(|| {
extract_params_from_raw_route(&self.inner.route_raw().unwrap_or_default())
})
}
fn is_endpoint(&self) -> bool {
*self
.is_endpoint
.get_or_init(|| guess_if_route_is_endpoint(&self.inner.route_raw().unwrap_or_default()))
}
fn get_variant_cache(&self, variant_id: &str) -> Option<&(Vec<ParameterDef>, bool)> {
let variant_caches = self.variant_caches.get_or_init(|| {
let mut map = FxHashMap::default();
for (id, path) in self.inner.variants() {
let params = extract_params_from_raw_route(&path);
let is_endpoint = guess_if_route_is_endpoint(&path);
map.insert(id, (params, is_endpoint));
}
map
});
variant_caches.get(variant_id)
}
}
impl<'a> InternalRoute for CachedRoute<'a> {
fn route_raw(&self) -> Option<String> {
self.inner.route_raw()
}
fn variants(&self) -> Vec<(String, String)> {
self.inner.variants()
}
fn url(&self, params: &PageParams) -> String {
build_url_with_params(
&self.route_raw().unwrap_or_default(),
self.get_cached_params(),
params,
self.is_endpoint(),
)
}
fn variant_url(&self, params: &PageParams, variant: &str) -> Result<String, String> {
let (params_def, is_endpoint) = self
.get_variant_cache(variant)
.ok_or_else(|| format!("Variant '{}' not found", variant))?;
let variants = self.inner.variants();
let variant_path = variants
.iter()
.find(|(id, _)| id == variant)
.map(|(_, path)| path.clone())
.ok_or_else(|| format!("Variant '{}' not found", variant))?;
Ok(build_url_with_params(
&variant_path,
params_def,
params,
*is_endpoint,
))
}
fn file_path(&self, params: &PageParams, output_dir: &Path) -> PathBuf {
let url = self.url(params);
build_file_path_from_url(&url, output_dir, self.is_endpoint())
}
fn variant_file_path(
&self,
params: &PageParams,
output_dir: &Path,
variant: &str,
) -> Result<PathBuf, String> {
let url = self.variant_url(params, variant)?;
let (_, is_endpoint) = self
.get_variant_cache(variant)
.ok_or_else(|| format!("Variant '{}' not found", variant))?;
Ok(build_file_path_from_url(&url, output_dir, *is_endpoint))
}
}
impl<'a> CachedRoute<'a> {
pub fn url_and_file_path(&self, params: &PageParams, output_dir: &Path) -> (String, PathBuf) {
let url = self.url(params);
let file_path = build_file_path_from_url(&url, output_dir, self.is_endpoint());
(url, file_path)
}
pub fn variant_url_and_file_path(
&self,
params: &PageParams,
output_dir: &Path,
variant: &str,
) -> Result<(String, PathBuf), String> {
let url = self.variant_url(params, variant)?;
let (_, is_endpoint) = self
.get_variant_cache(variant)
.ok_or_else(|| format!("Variant '{}' not found", variant))?;
let file_path = build_file_path_from_url(&url, output_dir, *is_endpoint);
Ok((url, file_path))
}
}
pub fn finish_route(
render_result: RenderResult,
page_assets: &RouteAssets,
route: String,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
match render_result {
RenderResult::Err(e) => Err(e),
RenderResult::Text(html) => {
let included_styles: Vec<_> = page_assets.included_styles().collect();
let included_scripts: Vec<_> = page_assets.included_scripts().collect();
if included_scripts.is_empty() && included_styles.is_empty() {
return Ok(html.into_bytes());
}
let element_content_handlers = vec![
element!("head", |el| {
for style in &included_styles {
el.append(
&format!("<link rel=\"stylesheet\" href=\"{}\">", style.url()),
lol_html::html_content::ContentType::Html,
);
}
for script in &included_scripts {
el.append(
&format!("<script src=\"{}\" type=\"module\"></script>", script.url()),
lol_html::html_content::ContentType::Html,
);
}
Ok(())
}),
];
let output = rewrite_str(
&html,
RewriteStrSettings {
element_content_handlers,
..RewriteStrSettings::new()
},
)?;
Ok(output.into_bytes())
}
RenderResult::Raw(content) => {
let included_styles: Vec<_> = page_assets.included_styles().collect();
let included_scripts: Vec<_> = page_assets.included_scripts().collect();
if !included_scripts.is_empty() || !included_styles.is_empty() {
Err(BuildError::InvalidRenderResult { route })?;
}
Ok(content)
}
}
}
pub type PagesResult = (PageParams, PageProps, PageTypedParams, Option<(String, String)>);
pub type PagesResults = Vec<PagesResult>;
pub type PageProps = Box<dyn Any + Send + Sync>;
pub type PageTypedParams = Box<dyn Any + Send + Sync>;
pub mod prelude {
pub use super::{
CachedRoute, DynamicRouteContext, FullRoute, Page, PageContext, PageParams, Pages,
PaginatedContentPage, PaginationPage, RenderResult, Route, RouteExt, paginate, redirect,
};
pub use crate::assets::{
Asset, Image, ImageFormat, ImageOptions, ImagePlaceholder, RenderWithAlt, Script, Style,
StyleOptions,
};
pub use crate::content::{ContentContext, ContentEntry, Entry, EntryInner, MarkdownContent};
pub use maudit_macros::{Params, route};
}
#[cfg(test)]
mod tests {
use super::*;
use rustc_hash::FxHashMap;
use std::path::Path;
struct TestPage {
route: String,
}
impl InternalRoute for TestPage {
fn route_raw(&self) -> Option<String> {
Some(self.route.clone())
}
}
#[test]
fn test_url_single_parameter() {
let page = TestPage {
route: "/articles/[slug]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("slug".to_string(), Some("hello-world".to_string()));
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/articles/hello-world/");
}
#[test]
fn test_url_multiple_parameters() {
let page = TestPage {
route: "/articles/tags/[tag]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("tag".to_string(), Some("rust".to_string()));
params.insert("page".to_string(), Some("2".to_string()));
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/articles/tags/rust/2/");
}
#[test]
fn test_url_multiple_parameters_different_lengths() {
let page = TestPage {
route: "/articles/tags/[tag]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert(
"tag".to_string(),
Some("development-experience".to_string()),
); params.insert("page".to_string(), Some("1".to_string())); let route_params = PageParams(params);
assert_eq!(
page.url(&route_params),
"/articles/tags/development-experience/1/"
);
}
#[test]
fn test_url_no_parameters() {
let page = TestPage {
route: "/about".to_string(),
};
let route_params = PageParams(FxHashMap::default());
assert_eq!(page.url(&route_params), "/about/");
}
#[test]
fn test_url_parameter_at_start() {
let page = TestPage {
route: "/[lang]/about".to_string(),
};
let mut params = FxHashMap::default();
params.insert("lang".to_string(), Some("en".to_string()));
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/en/about/");
}
#[test]
fn test_url_parameter_at_end() {
let page = TestPage {
route: "/api/users/[id]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("id".to_string(), Some("123".to_string()));
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/api/users/123/");
}
#[test]
fn test_file_path_single_parameter_non_endpoint() {
let page = TestPage {
route: "/articles/[slug]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("slug".to_string(), Some("hello-world".to_string()));
let route_params = PageParams(params);
let output_dir = Path::new("/dist");
let expected = Path::new("/dist/articles/hello-world/index.html");
assert_eq!(page.file_path(&route_params, output_dir), expected);
}
#[test]
fn test_file_path_multiple_parameters_non_endpoint() {
let page = TestPage {
route: "/articles/tags/[tag]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("tag".to_string(), Some("rust".to_string()));
params.insert("page".to_string(), Some("2".to_string()));
let route_params = PageParams(params);
let output_dir = Path::new("/dist");
let expected = Path::new("/dist/articles/tags/rust/2/index.html");
assert_eq!(page.file_path(&route_params, output_dir), expected);
}
#[test]
fn test_file_path_root_route() {
let page = TestPage {
route: "/".to_string(),
};
let route_params = PageParams(FxHashMap::default());
let output_dir = Path::new("/dist");
let expected = Path::new("/dist/index.html");
assert_eq!(page.file_path(&route_params, output_dir), expected);
}
#[test]
fn test_file_path_endpoint() {
let page = TestPage {
route: "/api/data.json".to_string(),
};
let route_params = PageParams(FxHashMap::default());
let output_dir = Path::new("/dist");
let expected = Path::new("/dist/api/data.json");
assert_eq!(page.file_path(&route_params, output_dir), expected);
}
#[test]
fn test_file_path_endpoint_no_leading_slash() {
let page = TestPage {
route: "404.html".to_string(),
};
let route_params = PageParams(FxHashMap::default());
let output_dir = Path::new("dist");
assert_eq!(page.url(&route_params), "/404.html");
assert_eq!(
page.file_path(&route_params, output_dir),
Path::new("dist/404.html")
);
}
#[test]
fn test_url_no_leading_slash_non_endpoint() {
let page = TestPage {
route: "about".to_string(),
};
let route_params = PageParams(FxHashMap::default());
assert_eq!(page.url(&route_params), "/about/");
assert_eq!(
page.file_path(&route_params, Path::new("dist")),
Path::new("dist/about/index.html")
);
}
#[test]
#[should_panic(expected = "Route \"/articles/[slug]\" is missing parameter \"slug\"")]
fn test_url_missing_parameter_panics() {
let page = TestPage {
route: "/articles/[slug]".to_string(),
};
let route_params = PageParams(FxHashMap::default());
page.url(&route_params);
}
#[test]
#[should_panic(expected = "Route \"/articles/tags/[tag]/[page]\" is missing parameter \"tag\"")]
fn test_file_path_missing_parameter_panics() {
let page = TestPage {
route: "/articles/tags/[tag]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("page".to_string(), Some("1".to_string()));
let route_params = PageParams(params);
let output_dir = Path::new("/dist");
page.file_path(&route_params, output_dir);
}
#[test]
fn test_paginate_generic_function() {
let tags = vec!["rust", "javascript", "python", "go", "typescript"];
let routes = paginate(&tags, 2, |page| {
let mut params = FxHashMap::default();
params.insert("page".to_string(), Some(page.to_string()));
PageParams(params)
});
assert_eq!(routes.len(), 3);
assert_eq!(routes[0].props.page, 0);
assert_eq!(routes[0].props.items.len(), 2);
assert_eq!(routes[0].props.items[0], &"rust");
assert_eq!(routes[0].props.items[1], &"javascript");
assert_eq!(routes[1].props.page, 1);
assert_eq!(routes[1].props.items.len(), 2);
assert_eq!(routes[1].props.items[0], &"python");
assert_eq!(routes[1].props.items[1], &"go");
assert_eq!(routes[2].props.page, 2);
assert_eq!(routes[2].props.items.len(), 1);
assert_eq!(routes[2].props.items[0], &"typescript");
}
#[test]
fn test_url_optional_parameter_with_value() {
let page = TestPage {
route: "/articles/[slug]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("slug".to_string(), Some("hello-world".to_string()));
params.insert("page".to_string(), Some("2".to_string()));
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/articles/hello-world/2/");
}
#[test]
fn test_url_optional_parameter_none() {
let page = TestPage {
route: "/articles/[slug]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("slug".to_string(), Some("hello-world".to_string()));
params.insert("page".to_string(), None);
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/articles/hello-world/");
}
#[test]
fn test_url_multiple_optional_parameters() {
let page = TestPage {
route: "/[lang]/articles/[category]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("lang".to_string(), None);
params.insert("category".to_string(), Some("rust".to_string()));
params.insert("page".to_string(), None);
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/articles/rust/");
}
#[test]
fn test_file_path_optional_parameter_with_value() {
let page = TestPage {
route: "/articles/[slug]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("slug".to_string(), Some("hello-world".to_string()));
params.insert("page".to_string(), Some("2".to_string()));
let route_params = PageParams(params);
let output_dir = Path::new("/dist");
let expected = Path::new("/dist/articles/hello-world/2/index.html");
assert_eq!(page.file_path(&route_params, output_dir), expected);
}
#[test]
fn test_file_path_optional_parameter_none() {
let page = TestPage {
route: "/articles/[slug]/[page]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("slug".to_string(), Some("hello-world".to_string()));
params.insert("page".to_string(), None);
let route_params = PageParams(params);
let output_dir = Path::new("/dist");
let expected = Path::new("/dist/articles/hello-world/index.html");
assert_eq!(page.file_path(&route_params, output_dir), expected);
}
#[test]
fn test_file_path_optional_parameter_endpoint() {
let page = TestPage {
route: "/api/[version]/data.json".to_string(),
};
let mut params = FxHashMap::default();
params.insert("version".to_string(), None);
let route_params = PageParams(params);
let output_dir = Path::new("/dist");
let expected = Path::new("/dist/api/data.json");
assert_eq!(page.file_path(&route_params, output_dir), expected);
}
#[test]
fn test_url_collapse_consecutive_slashes() {
let page = TestPage {
route: "/articles/[category]/[slug]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("category".to_string(), None);
params.insert("slug".to_string(), Some("hello-world".to_string()));
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/articles/hello-world/");
}
#[test]
fn test_url_collapse_multiple_consecutive_slashes() {
let page = TestPage {
route: "/articles/[cat1]/[cat2]/[cat3]/[slug]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("cat1".to_string(), None);
params.insert("cat2".to_string(), None);
params.insert("cat3".to_string(), None);
params.insert("slug".to_string(), Some("hello-world".to_string()));
let route_params = PageParams(params);
assert_eq!(page.url(&route_params), "/articles/hello-world/");
}
#[test]
fn test_file_path_collapse_consecutive_slashes() {
let page = TestPage {
route: "/articles/[category]/[slug]".to_string(),
};
let mut params = FxHashMap::default();
params.insert("category".to_string(), None);
params.insert("slug".to_string(), Some("hello-world".to_string()));
let route_params = PageParams(params);
let output_dir = Path::new("/dist");
let expected = Path::new("/dist/articles/hello-world/index.html");
assert_eq!(page.file_path(&route_params, output_dir), expected);
}
#[test]
fn test_redirect_simple_url() {
let result = redirect("https://example.com");
match result {
RenderResult::Text(html) => {
assert_eq!(
html,
r#"<meta http-equiv="refresh" content="0; url=https://example.com" />"#
);
}
_ => panic!("Expected RenderResult::Text variant"),
}
}
}