use std::collections::HashMap;
use std::path::Path;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use futures::StreamExt;
use tower::ServiceExt;
use super::{ManifestEntry, StaticManifest, StaticParams, StaticRouteMeta, url_to_file_path};
const DEFAULT_CONCURRENCY: usize = 8;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BuildError {
#[error("Route {path} returned HTTP {status} (expected 2xx)")]
NonSuccessStatus {
path: String,
status: StatusCode,
},
#[error("Failed to read response body for {path}: {source}")]
BodyRead {
path: String,
source: axum::Error,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Params function for route {path} returned no parameter sets")]
EmptyParams {
path: String,
},
}
struct RenderJob {
url: String,
revalidate: Option<u64>,
content_type: Option<&'static str>,
}
async fn expand_route(
meta: &StaticRouteMeta,
router: &axum::Router,
) -> Result<Vec<RenderJob>, BuildError> {
match meta.params_fn {
None => {
Ok(vec![RenderJob {
url: meta.path.to_owned(),
revalidate: meta.revalidate,
content_type: meta.content_type,
}])
}
Some(params_fn) => {
let param_sets = params_fn(router.clone()).await;
if param_sets.is_empty() {
return Err(BuildError::EmptyParams {
path: meta.path.to_owned(),
});
}
let jobs = param_sets
.into_iter()
.map(|params| {
let url = substitute_params(meta.path, ¶ms);
RenderJob {
url,
revalidate: meta.revalidate,
content_type: meta.content_type,
}
})
.collect();
Ok(jobs)
}
}
}
fn substitute_params(pattern: &str, params: &StaticParams) -> String {
let mut result = pattern.to_owned();
for (key, value) in params {
let placeholder = format!("{{{key}}}");
result = result.replace(&placeholder, value);
}
result
}
fn recorded_content_type(
headers: &axum::http::HeaderMap,
url: &str,
declared: Option<&str>,
) -> (Option<String>, bool) {
if let Some(value) = super::middleware::usable_recorded_content_type(declared) {
return (Some(value.to_owned()), true);
}
(inferred_content_type(headers, url), false)
}
fn inferred_content_type(headers: &axum::http::HeaderMap, url: &str) -> Option<String> {
let raw = headers
.get(axum::http::header::CONTENT_TYPE)?
.to_str()
.ok()?;
let value = super::middleware::usable_recorded_content_type(Some(raw))?;
Some(super::middleware::effective_content_type(value, url).to_owned())
}
pub async fn render_static_routes(
router: axum::Router,
metas: &[StaticRouteMeta],
dist_dir: &Path,
) -> Result<(), BuildError> {
let mut jobs = Vec::new();
for meta in metas {
let expanded = expand_route(meta, &router).await?;
eprintln!(" Route {} -> {} page(s)", meta.path, expanded.len());
jobs.extend(expanded);
}
let staging = dist_dir.with_extension("staging");
if tokio::fs::try_exists(&staging).await.unwrap_or(false) {
tokio::fs::remove_dir_all(&staging).await?;
}
tokio::fs::create_dir_all(&staging).await?;
for job in &jobs {
let file_path = url_to_file_path(&job.url);
let full_path = staging.join(&file_path);
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
}
let results: Vec<Result<(String, ManifestEntry), BuildError>> =
futures::stream::iter(jobs.into_iter().map(|job| {
let router = router.clone();
let staging = staging.clone();
let url = job.url;
let revalidate = job.revalidate;
let declared = job.content_type;
async move {
eprintln!(" Rendering {url} ...");
let response = router
.oneshot(
Request::builder()
.uri(&url)
.extension(super::RenderDeadlineExempt)
.body(Body::empty())
.expect("valid request"),
)
.await
.expect("router infallible");
if !response.status().is_success() {
return Err(BuildError::NonSuccessStatus {
path: url,
status: response.status(),
});
}
let (content_type, explicit) =
recorded_content_type(response.headers(), &url, declared);
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.map_err(|e| BuildError::BodyRead {
path: url.clone(),
source: e,
})?;
let file_path = url_to_file_path(&url);
let full_path = staging.join(&file_path);
tokio::fs::write(&full_path, &body_bytes).await?;
Ok((
url,
ManifestEntry::new(file_path)
.with_revalidate(revalidate)
.with_content_type(content_type)
.with_content_type_explicit(explicit),
))
}
}))
.buffer_unordered(DEFAULT_CONCURRENCY)
.collect()
.await;
let mut manifest_routes = HashMap::new();
for result in results {
match result {
Ok((path, entry)) => {
manifest_routes.insert(path, entry);
}
Err(e) => {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(e);
}
}
}
let manifest = StaticManifest::new(manifest_routes);
let json = serde_json::to_string_pretty(&manifest)?;
tokio::fs::write(staging.join("manifest.json"), json).await?;
if tokio::fs::try_exists(dist_dir).await.unwrap_or(false) {
tokio::fs::remove_dir_all(dist_dir).await?;
}
tokio::fs::rename(&staging, dist_dir).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::static_gen::StaticRouteMeta;
use std::future::Future;
use std::pin::Pin;
fn test_meta(path: &'static str, name: &'static str) -> StaticRouteMeta {
StaticRouteMeta {
path,
name,
revalidate: None,
params_fn: None,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
}
}
fn test_meta_with_revalidate(
path: &'static str,
name: &'static str,
revalidate: u64,
) -> StaticRouteMeta {
StaticRouteMeta {
path,
name,
revalidate: Some(revalidate),
params_fn: None,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
}
}
fn slug_params_hello_world(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async {
vec![
crate::static_params! { "slug" => "hello" },
crate::static_params! { "slug" => "world" },
]
})
}
fn slug_params_alpha_beta(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async {
vec![
crate::static_params! { "slug" => "alpha" },
crate::static_params! { "slug" => "beta" },
]
})
}
fn multi_params(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async {
vec![
crate::static_params! { "year" => "2026", "slug" => "hello" },
crate::static_params! { "year" => "2025", "slug" => "world" },
]
})
}
fn slug_params_hello(
_router: axum::Router,
) -> Pin<Box<dyn Future<Output = Vec<StaticParams>> + Send>> {
Box::pin(async { vec![crate::static_params! { "slug" => "hello" }] })
}
#[test]
fn render_static_routes_future_is_send() {
fn assert_send<T: Send>(_: &T) {}
let fut = render_static_routes(axum::Router::new(), &[], Path::new("unused"));
assert_send(&fut);
}
fn html_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|uri: axum::http::Uri| async move {
axum::response::Html(format!("<h1>{}</h1>", uri.path()))
}))
}
fn echo_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|uri: axum::http::Uri| async move {
format!("Hello from {}", uri.path())
}))
}
#[tokio::test]
async fn renders_single_route_to_dist() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result =
render_static_routes(echo_router(), &[test_meta("/about", "about")], &dist).await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
let html = std::fs::read_to_string(dist.join("about/index.html")).unwrap();
assert_eq!(html, "Hello from /about");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 1);
assert!(manifest.routes.contains_key("/about"));
}
#[tokio::test]
async fn renders_root_route() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result = render_static_routes(echo_router(), &[test_meta("/", "index")], &dist).await;
assert!(result.is_ok());
let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
assert_eq!(html, "Hello from /");
}
#[tokio::test]
async fn rejects_non_2xx_response() {
let router =
axum::Router::new().fallback(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "boom") });
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result = render_static_routes(router, &[test_meta("/about", "about")], &dist).await;
assert!(result.is_err());
assert!(!dist.exists(), "dist should not exist after failed build");
let staging = dist.with_extension("staging");
assert!(
!staging.exists(),
"staging dir should be cleaned up after failed build"
);
}
#[tokio::test]
async fn cleans_stale_dist_before_build() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).unwrap();
std::fs::write(dist.join("stale.html"), "old").unwrap();
let result =
render_static_routes(echo_router(), &[test_meta("/about", "about")], &dist).await;
assert!(result.is_ok());
assert!(!dist.join("stale.html").exists());
assert!(dist.join("about/index.html").exists());
}
#[tokio::test]
async fn renders_multiple_routes_concurrently() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result = render_static_routes(
echo_router(),
&[
test_meta("/", "index"),
test_meta("/about", "about"),
test_meta("/contact", "contact"),
],
&dist,
)
.await;
assert!(result.is_ok());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 3);
assert!(dist.join("index.html").exists());
assert!(dist.join("about/index.html").exists());
assert!(dist.join("contact/index.html").exists());
}
#[test]
fn substitute_params_single() {
let params = crate::static_params! { "slug" => "hello-world" };
let result = substitute_params("/posts/{slug}", ¶ms);
assert_eq!(result, "/posts/hello-world");
}
#[test]
fn substitute_params_multiple() {
let params = crate::static_params! {
"year" => "2026",
"slug" => "hello",
};
let result = substitute_params("/blog/{year}/{slug}", ¶ms);
assert_eq!(result, "/blog/2026/hello");
}
#[test]
fn substitute_params_no_placeholders() {
let params = StaticParams::new();
let result = substitute_params("/about", ¶ms);
assert_eq!(result, "/about");
}
#[tokio::test]
async fn renders_parameterized_route() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = StaticRouteMeta {
path: "/posts/{slug}",
name: "show_post",
revalidate: None,
params_fn: Some(slug_params_hello_world),
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
};
let result = render_static_routes(echo_router(), &[meta], &dist).await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
let hello_html = std::fs::read_to_string(dist.join("posts/hello/index.html")).unwrap();
assert_eq!(hello_html, "Hello from /posts/hello");
let world_html = std::fs::read_to_string(dist.join("posts/world/index.html")).unwrap();
assert_eq!(world_html, "Hello from /posts/world");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 2);
assert!(manifest.routes.contains_key("/posts/hello"));
assert!(manifest.routes.contains_key("/posts/world"));
}
#[tokio::test]
async fn renders_multi_param_route() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = StaticRouteMeta {
path: "/blog/{year}/{slug}",
name: "blog_post",
revalidate: None,
params_fn: Some(multi_params),
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
};
let result = render_static_routes(echo_router(), &[meta], &dist).await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
assert!(dist.join("blog/2026/hello/index.html").exists());
assert!(dist.join("blog/2025/world/index.html").exists());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 2);
assert!(manifest.routes.contains_key("/blog/2026/hello"));
assert!(manifest.routes.contains_key("/blog/2025/world"));
}
#[tokio::test]
async fn mixed_simple_and_parameterized_routes() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let metas = vec![
test_meta("/", "index"),
test_meta("/about", "about"),
StaticRouteMeta {
path: "/posts/{slug}",
name: "show_post",
revalidate: None,
params_fn: Some(slug_params_alpha_beta),
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
},
];
let result = render_static_routes(echo_router(), &metas, &dist).await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(manifest.routes.len(), 4);
assert!(manifest.routes.contains_key("/"));
assert!(manifest.routes.contains_key("/about"));
assert!(manifest.routes.contains_key("/posts/alpha"));
assert!(manifest.routes.contains_key("/posts/beta"));
}
#[tokio::test]
async fn parameterized_route_manifest_includes_revalidate() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = StaticRouteMeta {
path: "/posts/{slug}",
name: "show_post",
revalidate: Some(3600),
params_fn: Some(slug_params_hello),
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
};
let result = render_static_routes(echo_router(), &[meta], &dist).await;
assert!(result.is_ok());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
let entry = manifest.routes.get("/posts/hello").unwrap();
assert_eq!(entry.revalidate, Some(3600));
}
#[tokio::test]
async fn records_handler_declared_content_type_in_manifest() {
fn xml_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/xml")],
"<urlset/>",
)
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let result =
render_static_routes(xml_router(), &[test_meta("/sitemap.xml", "sitemap")], &dist)
.await;
assert!(result.is_ok(), "render failed: {:?}", result.err());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
let entry = manifest.routes.get("/sitemap.xml").expect("sitemap route");
assert_eq!(entry.file, "sitemap.xml/index.html");
assert_eq!(
entry.content_type.as_deref(),
Some("application/xml"),
"the handler's declared Content-Type must be recorded at generation time"
);
}
#[tokio::test]
async fn records_content_type_unreachable_by_extension_heuristic() {
fn rss_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/rss+xml")],
"<rss/>",
)
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(rss_router(), &[test_meta("/feed", "feed")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/feed"].content_type.as_deref(),
Some("application/rss+xml")
);
}
#[tokio::test]
async fn records_content_type_for_each_parameterized_page() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = StaticRouteMeta {
path: "/posts/{slug}",
name: "show_post",
revalidate: None,
params_fn: Some(slug_params_hello_world),
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
};
render_static_routes(html_router(), &[meta], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
for route in ["/posts/hello", "/posts/world"] {
assert_eq!(
manifest.routes[route].content_type.as_deref(),
Some("text/html; charset=utf-8"),
"{route} must carry the declared Content-Type"
);
}
}
#[tokio::test]
async fn records_no_content_type_when_handler_declares_none() {
fn bare_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
axum::response::Response::builder()
.status(StatusCode::OK)
.body(axum::body::Body::from("bare"))
.unwrap()
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(bare_router(), &[test_meta("/bare", "bare")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert!(
manifest.routes["/bare"].content_type.is_none(),
"no declared type must record as None, not as a guess"
);
}
#[tokio::test]
async fn skips_non_ascii_content_type() {
fn opaque_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
let mut response = axum::response::Response::new(axum::body::Body::from("x"));
response.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_bytes(b"text/\xffhtml").unwrap(),
);
response
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(opaque_router(), &[test_meta("/weird", "weird")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert!(
manifest.routes["/weird"].content_type.is_none(),
"a non-visible-ASCII Content-Type must not be recorded"
);
}
#[tokio::test]
async fn records_content_type_with_legal_tab_ows() {
fn tabbed_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
let mut response = axum::response::Response::new(axum::body::Body::from("<rss/>"));
response.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_bytes(b"application/rss+xml;\tprofile=\"x\"")
.expect("tab is a legal HeaderValue byte"),
);
response
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(tabbed_router(), &[test_meta("/feed", "feed")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
let recorded = manifest.routes["/feed"].content_type.as_deref();
assert_eq!(
recorded,
Some("application/rss+xml;\tprofile=\"x\""),
"tab OWS is legal in a media type and must be recorded verbatim"
);
assert_eq!(
super::super::resolved_content_type(
recorded,
"/feed",
std::path::Path::new("feed/index.html"),
),
"application/rss+xml;\tprofile=\"x\"",
"generation and serving must agree, or the recorded value is dead \
data and the route silently falls back to the derivation"
);
}
#[tokio::test]
async fn records_text_plain_for_bare_string_handler() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(echo_router(), &[test_meta("/about", "about")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/about"].content_type.as_deref(),
Some("text/plain; charset=utf-8"),
"a bare-String handler declares text/plain; record what it declared"
);
}
#[tokio::test]
async fn skips_empty_content_type() {
fn empty_ct_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
let mut response = axum::response::Response::new(axum::body::Body::from("x"));
response.headers_mut().insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static(""),
);
response
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(empty_ct_router(), &[test_meta("/empty", "empty")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert!(
manifest.routes["/empty"].content_type.is_none(),
"an empty Content-Type must not be recorded"
);
}
#[tokio::test]
async fn records_extension_type_for_generic_text_plain_over_a_recognized_extension() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(echo_router(), &[test_meta("/theme.css", "theme")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/theme.css"].content_type.as_deref(),
Some("text/css; charset=utf-8"),
"records the extension type, not the String default"
);
}
#[tokio::test]
async fn records_extension_type_for_generic_octet_stream_over_a_recognized_extension() {
fn bytes_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
b"\x89PNG\r\n\x1a\n".to_vec()
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(bytes_router(), &[test_meta("/logo.png", "logo")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/logo.png"].content_type.as_deref(),
Some("image/png"),
"records the extension type, not the Vec<u8> default"
);
}
#[tokio::test]
async fn records_explicit_type_that_contradicts_the_route_extension() {
fn json_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
r#"{"notes":[]}"#,
)
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(json_router(), &[test_meta("/notes.txt", "notes")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/notes.txt"].content_type.as_deref(),
Some("application/json"),
"an explicit declaration is intent and must be recorded"
);
}
#[tokio::test]
async fn generic_default_agreeing_with_the_extension_is_recorded() {
fn plain_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async { "hello".to_owned() }))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(plain_router(), &[test_meta("/notes.txt", "notes")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/notes.txt"].content_type.as_deref(),
Some("text/plain; charset=utf-8"),
"a generic default that agrees with the route extension has nothing \
to clobber, so it must be recorded rather than dropped"
);
}
#[tokio::test]
async fn generic_default_is_recorded_for_an_unrecognized_extension() {
fn bytes_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async { vec![0u8, 1, 2, 3] }))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(bytes_router(), &[test_meta("/data.bin", "data")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/data.bin"].content_type.as_deref(),
Some("application/octet-stream")
);
}
#[tokio::test]
async fn explicitly_distinct_generic_type_is_recorded_on_an_extensioned_route() {
fn plain_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "text/plain")],
"not really css",
)
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(plain_router(), &[test_meta("/theme.css", "theme")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/theme.css"].content_type.as_deref(),
Some("text/plain"),
"a distinctly-spelled declaration is intent, not an inferred default"
);
}
#[tokio::test]
async fn generic_default_is_recorded_for_an_extension_outside_the_asset_table() {
fn pdf_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
"%PDF-1.7",
)
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(pdf_router(), &[test_meta("/report.pdf", "report")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/report.pdf"].content_type.as_deref(),
Some("application/octet-stream"),
".pdf is not in the asset table, so there is no extension to prefer"
);
}
fn declared(path: &'static str, content_type: &'static str) -> StaticRouteMeta {
StaticRouteMeta {
content_type: Some(content_type),
..test_meta(path, "declared")
}
}
#[tokio::test]
async fn declared_octet_stream_is_recorded_on_a_png_route() {
fn bytes_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
b"\x89PNG\r\n\x1a\n".to_vec()
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = declared("/logo.png", "application/octet-stream");
render_static_routes(bytes_router(), &[meta], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
let entry = &manifest.routes["/logo.png"];
assert_eq!(
entry.content_type.as_deref(),
Some("application/octet-stream")
);
assert!(entry.content_type_explicit);
}
#[tokio::test]
async fn declared_type_wins_over_the_handler_header() {
fn json_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
"{}",
)
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(json_router(), &[declared("/data", "text/csv")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/data"].content_type.as_deref(),
Some("text/csv")
);
}
#[tokio::test]
async fn declared_type_is_recorded_when_the_handler_sets_none() {
fn bare_router() -> axum::Router {
axum::Router::new().fallback(axum::routing::get(|| async {
axum::response::Response::new(axum::body::Body::from("bare"))
}))
}
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(
bare_router(),
&[declared("/feed", "application/rss+xml")],
&dist,
)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/feed"].content_type.as_deref(),
Some("application/rss+xml")
);
}
#[tokio::test]
async fn declared_text_plain_is_recorded_on_a_css_route() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = declared("/theme.css", "text/plain; charset=utf-8");
render_static_routes(echo_router(), &[meta], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/theme.css"].content_type.as_deref(),
Some("text/plain; charset=utf-8")
);
}
#[tokio::test]
async fn declared_type_applies_to_each_parameterized_page() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = StaticRouteMeta {
path: "/posts/{slug}",
params_fn: Some(slug_params_hello_world),
content_type: Some("text/markdown"),
..test_meta("/posts/{slug}", "show_post")
};
render_static_routes(html_router(), &[meta], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
for route in ["/posts/hello", "/posts/world"] {
let entry = &manifest.routes[route];
assert_eq!(entry.content_type.as_deref(), Some("text/markdown"));
assert!(entry.content_type_explicit);
}
}
#[tokio::test]
async fn declared_type_is_trimmed_when_recorded() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = declared("/data", " text/csv ");
render_static_routes(echo_router(), &[meta], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert_eq!(
manifest.routes["/data"].content_type.as_deref(),
Some("text/csv")
);
}
#[tokio::test]
async fn inferred_type_is_not_marked_explicit() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
render_static_routes(echo_router(), &[test_meta("/theme.css", "theme")], &dist)
.await
.expect("render");
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
assert!(!manifest.routes["/theme.css"].content_type_explicit);
}
#[tokio::test]
async fn simple_route_with_revalidate() {
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path().join("dist");
let meta = test_meta_with_revalidate("/about", "about", 60);
let result = render_static_routes(echo_router(), &[meta], &dist).await;
assert!(result.is_ok());
let manifest = StaticManifest::load(&dist.join("manifest.json")).unwrap();
let entry = manifest.routes.get("/about").unwrap();
assert_eq!(entry.revalidate, Some(60));
}
}