use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use crate::discovery::{DiscoveredRoute, discover_routes};
const ROUTE_METHODS: &[(&str, &str)] = &[
("get", "GET"),
("post", "POST"),
("put", "PUT"),
("patch", "PATCH"),
("delete", "DELETE"),
("head", "HEAD"),
("options", "OPTIONS"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
struct RouteMethod {
fn_name: &'static str,
method_const: &'static str,
}
pub fn sync_public_dir(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
let manifest_dir = PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set in build.rs"),
);
let abs_src = manifest_dir.join(src.as_ref());
let abs_dst = manifest_dir.join(dst.as_ref());
if !abs_src.is_dir() {
return Ok(());
}
println!("cargo:rerun-if-changed={}", abs_src.display());
std::fs::create_dir_all(&abs_dst)?;
mirror_dir(&abs_src, &abs_dst)
}
fn mirror_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
use std::collections::HashSet;
let mut seen: HashSet<std::ffi::OsString> = HashSet::new();
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
seen.insert(name.clone());
let from = entry.path();
let to = dst.join(&name);
let ft = entry.file_type()?;
if ft.is_dir() {
std::fs::create_dir_all(&to)?;
mirror_dir(&from, &to)?;
} else if ft.is_file() {
let needs_copy = match (std::fs::metadata(&to), std::fs::metadata(&from)) {
(Ok(dst_meta), Ok(src_meta)) => {
dst_meta.len() != src_meta.len()
|| match (dst_meta.modified(), src_meta.modified()) {
(Ok(d), Ok(s)) => d < s,
_ => true,
}
}
_ => true,
};
if needs_copy {
std::fs::copy(&from, &to)?;
}
}
}
for entry in std::fs::read_dir(dst)? {
let entry = entry?;
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') || seen.contains(&name) {
continue;
}
let path = entry.path();
if entry.file_type()?.is_dir() {
std::fs::remove_dir_all(&path)?;
} else {
std::fs::remove_file(&path)?;
}
}
Ok(())
}
pub fn emit_registry(
app_dir: impl AsRef<Path>,
_includer_path_unused: impl AsRef<Path>,
out_name: &str,
) -> std::io::Result<()> {
let manifest_dir = PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set in build.rs"),
);
let abs_app = manifest_dir.join(app_dir.as_ref()).canonicalize()?;
let routes = discover_routes(&abs_app);
let code = generate_code(&routes);
println!("cargo:rerun-if-env-changed=NEXTRS_VERBOSE");
print_client_summary_if_verbose(&routes);
print_loading_warnings(&routes);
#[cfg(not(feature = "tsx"))]
if routes.iter().any(|r| {
r.page.tsx.is_some()
|| r.layout.tsx.is_some()
|| r.loading.tsx.is_some()
|| r.not_found.tsx.is_some()
}) {
println!(
"cargo:warning=nextrs: .tsx convention files found but the `tsx` feature is not enabled — \
enable it in [build-dependencies] and call nextrs::bundle::bundle_pages, or /dist/*.js will 404"
);
}
let out_dir = std::env::var_os("OUT_DIR").expect("OUT_DIR must be set in build.rs");
let out_path = Path::new(&out_dir).join(out_name);
std::fs::write(&out_path, &code)?;
if let Some(target_dir) = manifest_dir.ancestors().find_map(|p| {
let candidate = p.join("target");
if candidate.is_dir() {
Some(candidate)
} else {
None
}
}) {
let inspect_dir = target_dir.join("nextrs");
if std::fs::create_dir_all(&inspect_dir).is_ok() {
let _ = std::fs::write(inspect_dir.join(out_name), &code);
let _ = write_client_summary_file(&inspect_dir, out_name, &routes);
}
}
println!("cargo:rerun-if-changed={}", abs_app.display());
Ok(())
}
pub fn emit_seeds(app_dir: impl AsRef<Path>, out_name: &str) -> std::io::Result<()> {
let manifest_dir = PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set in build.rs"),
);
let abs_app = manifest_dir.join(app_dir.as_ref()).canonicalize()?;
let routes = discover_routes(&abs_app);
let mut out = String::new();
out.push_str("// @generated by nextrs::build::emit_seeds. Do not edit by hand.\n");
for (i, route) in routes.iter().enumerate() {
let Some(route_file) = &route.route else {
continue;
};
let Ok(source) = std::fs::read_to_string(route_file) else {
continue;
};
if has_public_async_method(&source, "get")
&& method_has_openapi_annotation(&source, "get")
&& get_is_seed_eligible(&source)
{
let module = mod_name(i, "route");
let alias = url_snake(&route.url_path);
let _ = writeln!(
out,
"#[allow(unused_imports)]\npub(crate) use crate::{} as {};",
module, alias
);
let _ = writeln!(
out,
"#[allow(unused_imports)]\npub(crate) use crate::{}::__nextrs_seed_get as get_{};",
module, alias
);
}
}
let out_dir = std::env::var_os("OUT_DIR").expect("OUT_DIR must be set in build.rs");
std::fs::write(Path::new(&out_dir).join(out_name), &out)?;
if let Some(target_dir) = manifest_dir.ancestors().find_map(|p| {
let candidate = p.join("target");
candidate.is_dir().then_some(candidate)
}) {
let inspect_dir = target_dir.join("nextrs");
if std::fs::create_dir_all(&inspect_dir).is_ok() {
let _ = std::fs::write(inspect_dir.join(out_name), &out);
}
}
Ok(())
}
fn url_snake(url: &str) -> String {
let parts: Vec<String> = url
.split('/')
.filter(|s| !s.is_empty())
.map(
|seg| match seg.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
Some(param) => format!("by_{}", param.to_lowercase()),
None => seg.replace('-', "_").to_lowercase(),
},
)
.collect();
if parts.is_empty() {
"root".to_string()
} else {
parts.join("_")
}
}
fn get_is_seed_eligible(source: &str) -> bool {
let Some(start) = source.find("pub async fn get") else {
return false;
};
let sig_region = &source[start..];
let Some(body_start) = sig_region.find('{') else {
return false;
};
let sig = &sig_region[..body_start];
let Some(open) = sig.find('(') else {
return false;
};
let mut depth = 0usize;
let mut close = None;
for (i, c) in sig[open..].char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
close = Some(open + i);
break;
}
}
_ => {}
}
}
let Some(close) = close else { return false };
let args = sig[open + 1..close].trim().trim_end_matches(',');
let ret = &sig[close..];
if !ret_is_seedable(ret) {
return false;
}
let top_level_commas = {
let mut depth = 0i32;
let mut count = 0;
for c in args.chars() {
match c {
'(' | '<' | '[' => depth += 1,
')' | '>' | ']' => depth -= 1,
',' if depth == 0 => count += 1,
_ => {}
}
}
count
};
if args.trim().is_empty() {
return true;
}
let extractorish = args.contains("Query") || args.contains("Path");
let arg_count = top_level_commas + 1;
extractorish
&& arg_count <= 2
&& (arg_count == 1
|| (args.matches("Query").count() >= 1 && args.matches("Path").count() >= 1))
}
fn ret_is_seedable(ret: &str) -> bool {
let normalized: String = ret.chars().filter(|c| !c.is_whitespace()).collect();
let Some(ty) = normalized.strip_prefix(")->") else {
return false; };
fn strip_qualifiers(mut s: &str) -> &str {
s = s.trim_start_matches("::");
while let Some(pos) = s.find("::") {
if s[..pos].chars().all(|c| c.is_alphanumeric() || c == '_') {
s = &s[pos + 2..];
} else {
break;
}
}
s
}
fn is_json_head(s: &str) -> bool {
strip_qualifiers(s).starts_with("Json<")
}
let ty = strip_qualifiers(ty);
is_json_head(ty)
|| ty
.strip_prefix("Result<")
.is_some_and(|ok_side| is_json_head(ok_side))
}
pub fn page_slug(url_path: &str) -> String {
if url_path == "/" {
return "index".to_string();
}
url_path
.trim_start_matches('/')
.replace('/', "-")
.replace('{', "_")
.replace('}', "_")
}
pub fn not_found_slug(url_path: &str) -> String {
if url_path == "/" {
"not-found".to_string()
} else {
format!("{}-not-found", page_slug(url_path))
}
}
fn generate_code(routes: &[DiscoveredRoute]) -> String {
let mut out = String::new();
out.push_str("// @generated by nextrs::build. Do not edit by hand.\n\n");
for route in routes {
if route.page.tsx.is_some() && (route.page.rs.is_some() || route.page.html.is_some()) {
let _ = writeln!(
out,
"::core::compile_error!({:?});",
format!(
"nextrs page conflict at {}: page.tsx cannot coexist with page.rs/page.html — one rendering model per segment",
route.url_path
)
);
}
if route.layout.tsx.is_some() && (route.layout.rs.is_some() || route.layout.html.is_some())
{
let _ = writeln!(
out,
"::core::compile_error!({:?});",
format!(
"nextrs layout conflict at {}: layout.tsx cannot coexist with layout.rs/layout.html — one layout model per segment",
route.url_path
)
);
}
if route.loading.tsx.is_some()
&& (route.loading.rs.is_some() || route.loading.html.is_some())
{
let _ = writeln!(
out,
"::core::compile_error!({:?});",
format!(
"nextrs loading conflict at {}: loading.tsx cannot coexist with loading.rs/loading.html — one loading model per segment",
route.url_path
)
);
}
if route.not_found.tsx.is_some()
&& (route.not_found.rs.is_some() || route.not_found.html.is_some())
{
let _ = writeln!(
out,
"::core::compile_error!({:?});",
format!(
"nextrs not-found conflict at {}: not-found.tsx cannot coexist with not-found.rs/not-found.html — one rendering model per segment",
route.url_path
)
);
}
if route.props.is_some() && route.page.tsx.is_none() {
let _ = writeln!(
out,
"::core::compile_error!({:?});",
format!(
"nextrs: props.rs at {} requires a page.tsx sibling — props feed a React page (Rust pages fetch their own data)",
route.url_path
)
);
}
}
for (i, route) in routes.iter().enumerate() {
if let Some(p) = &route.page.rs {
emit_path_mod(&mut out, &mod_name(i, "page"), p);
}
if let Some(p) = &route.layout.rs {
emit_path_mod(&mut out, &mod_name(i, "layout"), p);
}
if let Some(p) = &route.loading.rs {
emit_path_mod(&mut out, &mod_name(i, "loading"), p);
}
if let Some(p) = &route.not_found.rs {
emit_path_mod(&mut out, &mod_name(i, "notfound"), p);
}
if let Some(p) = &route.middleware {
emit_path_mod(&mut out, &mod_name(i, "middleware"), p);
}
if let Some(p) = &route.route {
emit_path_mod(&mut out, &mod_name(i, "route"), p);
}
if let Some(p) = &route.props {
emit_path_mod(&mut out, &mod_name(i, "props"), p);
}
}
out.push_str("\npub fn generated_registry() -> ::nextrs::conventions::RouteRegistry {\n");
out.push_str(" let mut registry = ::nextrs::conventions::RouteRegistry::new();\n");
for (i, route) in routes.iter().enumerate() {
let _ = writeln!(out, " registry.add(::nextrs::conventions::RouteEntry {{");
let _ = writeln!(out, " path: {:?}.to_string(),", route.url_path);
emit_page_slot(&mut out, i, route);
emit_slot(&mut out, "layout", i, &route.layout);
emit_loading_slot(&mut out, i, route, routes);
emit_middleware(&mut out, i, route);
emit_methods(&mut out, i, route);
emit_prefetch_slot(&mut out, i, route);
out.push_str(" });\n");
}
for (i, route) in routes.iter().enumerate() {
emit_not_found(&mut out, i, route);
}
out.push_str(" registry\n}\n");
emit_openapi(&mut out, routes);
out
}
fn emit_openapi(out: &mut String, routes: &[DiscoveredRoute]) {
let mut handler_paths: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for (i, route) in routes.iter().enumerate() {
let Some(route_file) = &route.route else {
continue;
};
let Ok(source) = std::fs::read_to_string(route_file) else {
continue;
};
let module = mod_name(i, "route");
for (fn_name, _) in ROUTE_METHODS {
if has_public_async_method(&source, fn_name)
&& method_has_openapi_annotation(&source, fn_name)
{
handler_paths.push(format!("{}::{}", module, fn_name));
if let Some(declared) = extract_annotated_path(&source, fn_name) {
if declared != route.url_path {
errors.push(format!(
"nextrs: `{}()` in the route.rs for {url} declares \
#[utoipa::path(path = {declared:?})], but its file-convention \
path is {url:?}. Update the annotation to path = {url:?}.",
fn_name,
url = route.url_path,
declared = declared,
));
}
}
}
}
}
for msg in &errors {
let _ = writeln!(out, "::core::compile_error!({:?});", msg);
}
out.push_str(
"\n/// OpenAPI document built from every `#[utoipa::path]`-annotated route.rs handler.\n",
);
if handler_paths.is_empty() {
out.push_str("pub fn generated_openapi() -> ::utoipa::openapi::OpenApi {\n");
out.push_str(" let mut doc = ::utoipa::openapi::OpenApiBuilder::new().build();\n");
out.push_str(" ::nextrs::openapi::normalize(&mut doc);\n");
out.push_str(" doc\n");
out.push_str("}\n");
return;
}
out.push_str("#[derive(::utoipa::OpenApi)]\n");
out.push_str("#[openapi(paths(\n");
for p in &handler_paths {
let _ = writeln!(out, " {},", p);
}
out.push_str("))]\n");
out.push_str("struct __NextrsOpenApi;\n\n");
out.push_str("pub fn generated_openapi() -> ::utoipa::openapi::OpenApi {\n");
out.push_str(" let mut doc = <__NextrsOpenApi as ::utoipa::OpenApi>::openapi();\n");
out.push_str(" ::nextrs::openapi::normalize(&mut doc);\n");
out.push_str(" doc\n");
out.push_str("}\n");
}
struct ClientStatus {
method: &'static str,
url_path: String,
in_client: bool,
}
fn client_status(routes: &[DiscoveredRoute]) -> Vec<ClientStatus> {
let mut out = Vec::new();
for route in routes {
let Some(route_file) = &route.route else {
continue;
};
let Ok(source) = std::fs::read_to_string(route_file) else {
continue;
};
for (fn_name, method_const) in ROUTE_METHODS {
if has_public_async_method(&source, fn_name) {
out.push(ClientStatus {
method: method_const,
url_path: route.url_path.clone(),
in_client: method_has_openapi_annotation(&source, fn_name),
});
}
}
}
out
}
fn has_props_backed_tsx_page(route: &DiscoveredRoute) -> bool {
route.page.tsx.is_some() && route.props.is_some()
}
fn loading_tsx_applies_to_any_props_route(
routes: &[DiscoveredRoute],
loading_route: &DiscoveredRoute,
) -> bool {
routes.iter().any(|route| {
has_props_backed_tsx_page(route)
&& entry_applies_to_path(&loading_route.url_path, &route.url_path)
})
}
fn print_loading_warnings(routes: &[DiscoveredRoute]) {
for route in routes.iter().filter(|route| route.loading.tsx.is_some()) {
if !loading_tsx_applies_to_any_props_route(routes, route) {
println!(
"cargo:warning=nextrs: {} has loading.tsx but no props-backed page.tsx route uses it",
route.url_path
);
}
}
}
fn route_depth(path: &str) -> usize {
if path == "/" {
0
} else {
path.matches('/').count()
}
}
fn entry_applies_to_path(entry_path: &str, target_path: &str) -> bool {
entry_path == "/"
|| target_path == entry_path
|| target_path
.strip_prefix(entry_path)
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn nearest_tsx_loading<'a>(
routes: &'a [DiscoveredRoute],
target_path: &str,
) -> Option<&'a DiscoveredRoute> {
routes
.iter()
.filter(|route| {
route.loading.tsx.is_some() && entry_applies_to_path(&route.url_path, target_path)
})
.max_by_key(|route| route_depth(&route.url_path))
}
pub fn loading_slug(url_path: &str) -> String {
format!("{}.loading", page_slug(url_path))
}
fn tsx_loading_shell(loading_route: &DiscoveredRoute) -> String {
let src = format!("/dist/{}.js", loading_slug(&loading_route.url_path));
format!(
r#"{}<div id="__nx_loading_root__"></div><script>import({:?});</script>"#,
tsx_document_head(),
src
)
}
fn tsx_document_head() -> String {
match std::env::var_os("CARGO_MANIFEST_DIR")
.map(|dir| std::path::Path::new(&dir).join("public/style.css"))
.filter(|p| p.is_file())
{
Some(css) => {
println!("cargo:rerun-if-changed={}", css.display());
let content = std::fs::read(&css).unwrap_or_default();
format!(
r#"<link rel="stylesheet" href="/style.css?v={}" />"#,
content_hash(&content)
)
}
None => r#"<link rel="stylesheet" href="/style.css" />"#.to_string(),
}
}
fn content_hash(bytes: &[u8]) -> String {
let mut hash: u64 = 0xcbf29ce484222325;
for b in bytes {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
fn client_summary_text(routes: &[DiscoveredRoute]) -> Option<String> {
let statuses = client_status(routes);
if statuses.is_empty() {
return None;
}
let in_client = statuses.iter().filter(|s| s.in_client).count();
let mut out = format!(
"nextrs: typed client generated for {in_client}/{} route.rs handler(s)\n",
statuses.len()
);
for s in &statuses {
let mark = if s.in_client {
"client ✓"
} else {
"no client (add #[nextrs::api])"
};
let _ = writeln!(out, " {:<7} {:<24} {}", s.method, s.url_path, mark);
}
Some(out)
}
fn client_summary_file_name(out_name: &str) -> String {
let stem = Path::new(out_name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("nextrs_routes");
format!("{stem}.client-summary.txt")
}
fn write_client_summary_file(
inspect_dir: &Path,
out_name: &str,
routes: &[DiscoveredRoute],
) -> std::io::Result<()> {
let path = inspect_dir.join(client_summary_file_name(out_name));
if let Some(summary) = client_summary_text(routes) {
std::fs::write(path, summary)?;
} else if let Err(err) = std::fs::remove_file(path) {
if err.kind() != std::io::ErrorKind::NotFound {
return Err(err);
}
}
Ok(())
}
fn print_client_summary_if_verbose(routes: &[DiscoveredRoute]) {
if !env_flag("NEXTRS_VERBOSE") {
return;
}
let Some(summary) = client_summary_text(routes) else {
return;
};
for line in summary.lines() {
println!("cargo:warning={line}");
}
}
fn env_flag(name: &str) -> bool {
std::env::var(name).is_ok_and(|value| {
matches!(
value.as_str(),
"1" | "true" | "TRUE" | "yes" | "YES" | "on" | "ON"
)
})
}
fn method_has_openapi_annotation(source: &str, fn_name: &str) -> bool {
annotation_region(source, fn_name).is_some()
}
fn fn_decl_index(source: &str, fn_name: &str) -> Option<usize> {
for prefix in [
"pub async fn ",
"pub(crate) async fn ",
"pub(super) async fn ",
] {
if let Some(idx) = source.find(&format!("{}{}", prefix, fn_name)) {
return Some(idx);
}
}
None
}
const OPENAPI_ATTRS: &[&str] = &["#[utoipa::path", "#[nextrs::api"];
fn annotation_region<'a>(source: &'a str, fn_name: &str) -> Option<&'a str> {
let idx = fn_decl_index(source, fn_name)?;
let before = &source[..idx];
let attr_pos = OPENAPI_ATTRS
.iter()
.filter_map(|marker| before.rfind(marker))
.max()?;
if before[attr_pos..].contains(" fn ") {
return None;
}
Some(&source[attr_pos..idx])
}
fn extract_annotated_path(source: &str, fn_name: &str) -> Option<String> {
let region = annotation_region(source, fn_name)?;
let bytes = region.as_bytes();
let is_ws = |b: u8| matches!(b, b' ' | b'\t' | b'\n' | b'\r');
let mut from = 0;
while let Some(rel) = region[from..].find("path") {
let p = from + rel;
from = p + 4;
let boundary_before = p == 0 || matches!(bytes[p - 1], b'(' | b',') || is_ws(bytes[p - 1]);
if !boundary_before {
continue;
}
let mut q = p + 4;
while q < bytes.len() && is_ws(bytes[q]) {
q += 1;
}
if q >= bytes.len() || bytes[q] != b'=' {
continue;
}
let mut r = q + 1;
while r < bytes.len() && is_ws(bytes[r]) {
r += 1;
}
if r >= bytes.len() || bytes[r] != b'"' {
continue;
}
let start = r + 1;
let end_rel = region[start..].find('"')?;
return Some(region[start..start + end_rel].to_string());
}
None
}
fn discover_route_methods(path: &Path) -> Vec<RouteMethod> {
let Ok(source) = std::fs::read_to_string(path) else {
return Vec::new();
};
ROUTE_METHODS
.iter()
.filter_map(|(fn_name, method_const)| {
if has_public_async_method(&source, fn_name) {
Some(RouteMethod {
fn_name,
method_const,
})
} else {
None
}
})
.collect()
}
fn has_public_async_method(source: &str, fn_name: &str) -> bool {
let needles = [
format!("pub async fn {}", fn_name),
format!("pub(crate) async fn {}", fn_name),
format!("pub(super) async fn {}", fn_name),
];
needles.iter().any(|needle| source.contains(needle))
}
fn emit_methods(out: &mut String, idx: usize, route: &DiscoveredRoute) {
let Some(route_file) = &route.route else {
out.push_str(" methods: vec![],\n");
return;
};
let methods = discover_route_methods(route_file);
if methods.is_empty() {
out.push_str(" methods: vec![],\n");
return;
}
if route.page.exists() && methods.iter().any(|m| m.method_const == "GET") {
let _ = writeln!(
out,
" methods: {{ compile_error!({:?}); vec![] }},",
format!(
"nextrs route conflict at {}: page owns GET, so route.rs cannot export get()",
route.url_path
)
);
return;
}
let m = mod_name(idx, "route");
out.push_str(" methods: vec![\n");
for method in methods {
let _ = writeln!(
out,
" (::nextrs::http::Method::{}, ::nextrs::conventions::route_method({}::{})),",
method.method_const, m, method.fn_name
);
}
out.push_str(" ],\n");
}
fn emit_prefetch_slot(out: &mut String, idx: usize, route: &DiscoveredRoute) {
let Some(props_path) = &route.props else {
out.push_str(" prefetch: None,\n");
return;
};
let props_mod = mod_name(idx, "props");
let entry_fn = if props_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "prefetch.rs")
{
"prefetch"
} else {
"props"
};
if route.url_path.contains('{') {
let _ = writeln!(
out,
" prefetch: Some(Box::new(|req| Box::pin(async move {{\n \
let (params, req) = ::nextrs::params::extract_params(req).await;\n \
{}::{}(req, params).await\n \
}}))),",
props_mod, entry_fn
);
} else {
let _ = writeln!(
out,
" prefetch: Some(Box::new(|req| Box::pin({}::{}(req)))),",
props_mod, entry_fn
);
}
}
fn emit_middleware(out: &mut String, idx: usize, route: &DiscoveredRoute) {
if route.middleware.is_some() {
let _ = writeln!(
out,
" middleware: Some(Box::new(|req| Box::pin({}::handle(req)))),",
mod_name(idx, "middleware")
);
} else {
out.push_str(" middleware: None,\n");
}
}
fn mod_name(idx: usize, slot: &str) -> String {
format!("__nextrs_route_{}_{}", idx, slot)
}
fn emit_path_mod(out: &mut String, name: &str, target: &Path) {
let abs = target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
let _ = writeln!(out, "#[path = {:?}]", abs.display().to_string());
let _ = writeln!(out, "mod {};\n", name);
}
fn emit_page_slot(out: &mut String, idx: usize, route: &DiscoveredRoute) {
let is_tsx_only =
route.page.tsx.is_some() && route.page.rs.is_none() && route.page.html.is_none();
if !is_tsx_only {
emit_slot(out, "page", idx, &route.page);
return;
}
let shell = tsx_page_shell();
let is_dynamic = route.url_path.contains('{');
if let Some(props_path) = &route.props {
let props_mod = mod_name(idx, "props");
let entry_fn = if props_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == "prefetch.rs")
{
"prefetch"
} else {
"props"
};
if is_dynamic {
let _ = writeln!(
out,
" page: Some(Box::new(|req| Box::pin(async move {{\n \
let (params, req) = ::nextrs::params::extract_params(req).await;\n \
let seeds = {}::{}(req, params.clone()).await;\n \
format!(\"{{}}{{}}{{}}\", params.to_script_tag(), seeds.to_script_tag(), {:?})\n \
}}))),",
props_mod, entry_fn, shell
);
} else {
let _ = writeln!(
out,
" page: Some(Box::new(|req| Box::pin(async move {{\n \
let seeds = {}::{}(req).await;\n \
format!(\"{{}}{{}}\", seeds.to_script_tag(), {:?})\n \
}}))),",
props_mod, entry_fn, shell
);
}
} else if is_dynamic {
let _ = writeln!(
out,
" page: Some(Box::new(|req| Box::pin(async move {{\n \
let (params, _req) = ::nextrs::params::extract_params(req).await;\n \
format!(\"{{}}{{}}\", params.to_script_tag(), {:?})\n \
}}))),",
shell
);
} else {
let _ = writeln!(
out,
" page: Some(::nextrs::conventions::static_page({:?})),",
shell
);
}
}
fn tsx_page_shell() -> String {
tsx_shell_with_src("/dist/__app_shell__.js")
}
fn tsx_standalone_shell(slug: &str) -> String {
tsx_shell_with_src(&format!("/dist/{slug}.js"))
}
fn tsx_shell_with_src(src: &str) -> String {
format!(
r#"{}<div id="__nx_root__"></div><script>
// Define a global `process.env` BEFORE any module chunk runs. Zero-copy Next.js
// code (and TanStack Router) reads process.env.* at module-init; rolldown's
// `define` only rewrites NODE_ENV, and with code-splitting the env shim can land
// in a sibling chunk with no ordering guarantee — so set it here in an inline
// (non-module) script, which always runs before the deferred module script.
window.process || (window.process = {{ env: {{}} }});
(() => {{
const renderError = (title, detail) => {{
const root = document.getElementById("__nx_root__");
if (!root || root.childElementCount > 0) return;
const wrap = document.createElement("div");
wrap.style.cssText = "box-sizing:border-box;margin:32px auto;max-width:860px;border:1px solid #f1a7a7;background:#fff7f7;color:#681414;border-radius:8px;padding:20px;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;";
const h = document.createElement("h1");
h.textContent = title;
h.style.cssText = "margin:0 0 10px;font-size:20px;line-height:1.2;";
const pre = document.createElement("pre");
pre.textContent = detail || "No error detail available.";
pre.style.cssText = "margin:0;white-space:pre-wrap;overflow:auto;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono',monospace;";
wrap.append(h, pre);
root.append(wrap);
}};
window.addEventListener("error", (event) => {{
renderError("Client-side page error", event.error?.stack || event.message || String(event));
}});
window.addEventListener("unhandledrejection", (event) => {{
const reason = event.reason;
renderError("Client-side async error", reason?.stack || reason?.message || String(reason));
}});
}})();
</script><script type="module" src="{}"></script>"#,
tsx_document_head(),
src
)
}
fn emit_not_found(out: &mut String, idx: usize, route: &DiscoveredRoute) {
let s = &route.not_found;
let path = &route.url_path;
if s.rs.is_some() {
let m = mod_name(idx, "notfound");
let _ = writeln!(
out,
" registry.add_not_found({:?}.to_string(), Box::new(|req| Box::pin({}::render(req))));",
path, m
);
} else if s.tsx.is_some() {
let shell = tsx_standalone_shell(¬_found_slug(path));
let _ = writeln!(
out,
" registry.add_not_found({:?}.to_string(), ::nextrs::conventions::static_page({:?}));",
path, shell
);
} else if let Some(html) = &s.html {
let path_lit = format!("{:?}", html.display().to_string());
let _ = writeln!(
out,
" registry.add_not_found({:?}.to_string(), ::nextrs::conventions::static_page(include_str!({})));",
path, path_lit
);
}
}
fn emit_loading_slot(
out: &mut String,
idx: usize,
route: &DiscoveredRoute,
routes: &[DiscoveredRoute],
) {
if route.loading.rs.is_some() || route.loading.html.is_some() {
emit_slot(out, "loading", idx, &route.loading);
return;
}
if has_props_backed_tsx_page(route) {
if let Some(loading_route) = nearest_tsx_loading(routes, &route.url_path) {
let shell = tsx_loading_shell(loading_route);
let _ = writeln!(
out,
" loading: Some(::nextrs::conventions::static_loading({:?})),",
shell
);
return;
}
}
out.push_str(" loading: None,\n");
}
fn emit_slot(out: &mut String, slot: &str, idx: usize, s: &crate::discovery::Slot) {
let m = mod_name(idx, slot);
match (&s.rs, &s.html) {
(Some(_), _) => {
match slot {
"page" => {
let _ = writeln!(
out,
" page: Some(Box::new(|req| Box::pin({}::render(req)))),",
m
);
}
"layout" => {
let _ = writeln!(out, " layout: Some(Box::new({}::render)),", m);
}
"loading" => {
let _ = writeln!(out, " loading: Some(Box::new({}::render)),", m);
}
_ => unreachable!("unknown slot {}", slot),
}
}
(None, Some(html)) => {
let path_lit = format!("{:?}", html.display().to_string());
match slot {
"page" => {
let _ = writeln!(
out,
" page: Some(::nextrs::conventions::static_page(include_str!({}))),",
path_lit
);
}
"layout" => {
let _ = writeln!(
out,
" layout: Some(::nextrs::conventions::static_layout(include_str!({}))),",
path_lit
);
}
"loading" => {
let _ = writeln!(
out,
" loading: Some(::nextrs::conventions::static_loading(include_str!({}))),",
path_lit
);
}
_ => unreachable!("unknown slot {}", slot),
}
}
(None, None) => {
let _ = writeln!(out, " {}: None,", slot);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn setup_app(structure: &[(&str, &[&str])]) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
for (dir, files) in structure {
let dir_path = tmp.path().join(dir);
fs::create_dir_all(&dir_path).unwrap();
for file in *files {
let body = if file.ends_with(".rs") {
if *file == "route.rs" {
"use axum::body::Body;\nuse axum::response::IntoResponse;\nuse http::{Request, StatusCode};\npub async fn post(_req: Request<Body>) -> impl IntoResponse { StatusCode::OK }\n"
} else if *file == "middleware.rs" {
"use axum::body::Body;\nuse http::Request;\npub async fn handle(req: Request<Body>) -> nextrs::conventions::MiddlewareResult { nextrs::conventions::MiddlewareResult::next(req) }\n"
} else {
"pub fn render() -> String { String::new() }"
}
} else {
"<html/>"
};
fs::write(dir_path.join(file), body).unwrap();
}
}
tmp
}
fn setup_app_with_file_bodies(structure: &[(&str, &[(&str, &str)])]) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
for (dir, files) in structure {
let dir_path = tmp.path().join(dir);
fs::create_dir_all(&dir_path).unwrap();
for (file, body) in *files {
fs::write(dir_path.join(file), body).unwrap();
}
}
tmp
}
#[test]
fn generates_expected_skeleton() {
let tmp = setup_app(&[
("", &["layout.rs", "middleware.rs", "page.rs"]),
("simple", &["page.rs"]),
("with-loading", &["page.rs", "loading.html"]),
("static-only", &["page.html", "layout.html"]),
]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(code.starts_with("// @generated by nextrs::build."));
assert!(
code.contains("#[path = ")
&& code.contains("mod __nextrs_route_0_page;")
&& code.contains("mod __nextrs_route_0_layout;")
&& code.contains("mod __nextrs_route_0_middleware;")
&& code.contains("mod __nextrs_route_1_page;")
&& code.contains("mod __nextrs_route_3_page;"),
"expected mod declarations missing:\n{}",
code
);
assert!(
code.contains("static_page(include_str!"),
"expected static_page macro for static-only page:\n{}",
code
);
assert!(
code.contains("static_layout(include_str!"),
"expected static_layout macro for static-only layout:\n{}",
code
);
assert!(
code.contains("static_loading(include_str!"),
"expected static_loading macro for with-loading.html:\n{}",
code
);
assert!(
code.contains("middleware: Some(Box::new(|req| Box::pin(__nextrs_route_0_middleware::handle(req))))"),
"expected middleware.rs handler wiring:\n{}",
code
);
assert_eq!(
code.matches("middleware: None,").count(),
3,
"expected middleware: None for routes without middleware:\n{}",
code
);
assert_eq!(code.matches("registry.add(").count(), 4);
assert!(code.contains("\"/\".to_string()"));
assert!(code.contains("\"/simple\".to_string()"));
assert!(code.contains("\"/with-loading\".to_string()"));
assert!(code.contains("\"/static-only\".to_string()"));
}
#[test]
fn rs_takes_precedence_over_html_when_both_present() {
let tmp = setup_app(&[("dual", &["page.rs", "page.html"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("Box::pin(__nextrs_route_0_page::render(req))"),
"expected .rs to win:\n{}",
code
);
assert!(
!code.contains("static_page(include_str!"),
"should not include the .html via static_page when .rs is present:\n{}",
code
);
}
#[test]
fn emitted_paths_are_absolute() {
let tmp = setup_app(&[("dyn", &["page.rs"]), ("static", &["page.html"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
for line in code.lines().filter(|l| l.starts_with("#[path =")) {
assert!(
line.contains("\"/"),
"non-absolute path in generated code: {}",
line
);
}
for line in code.lines().filter(|l| l.contains("include_str!")) {
assert!(
line.contains("include_str!(\"/"),
"non-absolute include_str! in generated code: {}",
line
);
}
}
#[test]
fn route_rs_emits_module_and_methods() {
let tmp = setup_app(&[("api/ping", &["route.rs"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("mod __nextrs_route_0_route;"),
"expected route.rs module declaration:\n{}",
code
);
assert!(
code.contains("::nextrs::http::Method::POST"),
"expected POST method entry:\n{}",
code
);
assert!(
code.contains("::nextrs::conventions::route_method(__nextrs_route_0_route::post)"),
"expected generated handler to call post(req):\n{}",
code
);
assert!(
!code.contains("methods: vec![],"),
"route.rs should not emit an empty methods vector:\n{}",
code
);
}
#[test]
fn route_rs_supports_multiple_methods() {
let route_body = r#"
use axum::body::Body;
use axum::response::IntoResponse;
use http::{Request, StatusCode};
pub async fn get(_req: Request<Body>) -> impl IntoResponse { StatusCode::OK }
pub async fn patch(_req: Request<Body>) -> impl IntoResponse { StatusCode::NO_CONTENT }
"#;
let tmp = setup_app_with_file_bodies(&[("api/item/[id]", &[("route.rs", route_body)])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(code.contains("\"/api/item/{id}\".to_string()"));
assert!(code.contains("::nextrs::http::Method::GET"));
assert!(code.contains("::nextrs::http::Method::PATCH"));
assert!(code.contains("route_method(__nextrs_route_0_route::get)"));
assert!(code.contains("route_method(__nextrs_route_0_route::patch)"));
}
#[test]
fn page_and_route_non_get_can_coexist() {
let tmp = setup_app(&[("reviews", &["page.rs", "route.rs"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(code.contains("\"/reviews\".to_string()"));
assert!(code.contains("__nextrs_route_0_page::render(req)"));
assert!(code.contains("::nextrs::http::Method::POST"));
assert!(!code.contains("compile_error!"));
}
#[test]
fn unannotated_routes_emit_empty_openapi() {
let tmp = setup_app(&[("api/ping", &["route.rs"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(code.contains("pub fn generated_openapi()"));
assert!(
code.contains("OpenApiBuilder::new().build()"),
"expected empty-spec fallback:\n{}",
code
);
assert!(
!code.contains("__NextrsOpenApi"),
"no derive struct when nothing is annotated:\n{}",
code
);
}
#[test]
fn annotated_handlers_are_collected_into_openapi() {
let route_body = r#"
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Pong { pub ok: bool }
#[utoipa::path(get, path = "/api/ping", responses((status = 200, body = Pong)))]
pub async fn get() -> Json<Pong> { Json(Pong { ok: true }) }
// Intentionally NOT annotated — should be excluded from the spec.
pub async fn post(_req: http::Request<axum::body::Body>) -> axum::http::StatusCode {
axum::http::StatusCode::CREATED
}
"#;
let tmp = setup_app_with_file_bodies(&[("api/ping", &[("route.rs", route_body)])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(code.contains("::nextrs::http::Method::GET"));
assert!(code.contains("::nextrs::http::Method::POST"));
assert!(code.contains("#[derive(::utoipa::OpenApi)]"));
assert!(
code.contains("__nextrs_route_0_route::get,"),
"annotated get() should be in paths():\n{}",
code
);
assert!(
!code.contains("__nextrs_route_0_route::post,"),
"unannotated post() must not be in paths():\n{}",
code
);
}
#[test]
fn openapi_annotation_detection_is_per_function() {
let src = r#"
#[utoipa::path(get, path = "/x")]
pub async fn get() -> &'static str { "x" }
pub async fn post() -> &'static str { "y" }
"#;
assert!(method_has_openapi_annotation(src, "get"));
assert!(!method_has_openapi_annotation(src, "post"));
}
#[test]
fn extracts_annotated_path_ignoring_attribute_name() {
let src = r#"
#[utoipa::path(
post,
path = "/api/ping",
responses((status = 200, body = Pong)),
)]
pub async fn post() -> &'static str { "x" }
"#;
assert_eq!(
extract_annotated_path(src, "post").as_deref(),
Some("/api/ping")
);
}
#[test]
fn no_path_literal_yields_none() {
let src = "#[utoipa::path(post)]\npub async fn post() -> &'static str { \"x\" }\n";
assert_eq!(extract_annotated_path(src, "post"), None);
}
#[test]
fn mismatched_path_emits_compile_error() {
let route_body = r#"
#[utoipa::path(get, path = "/wrong", responses((status = 200, body = Pong)))]
pub async fn get() -> axum::Json<Pong> { todo!() }
"#;
let tmp = setup_app_with_file_bodies(&[("api/ping", &[("route.rs", route_body)])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("compile_error!"),
"expected a compile_error for the path mismatch:\n{}",
code
);
assert!(
code.contains("/api/ping") && code.contains("/wrong"),
"compile_error should name both the declared and expected paths:\n{}",
code
);
}
#[test]
fn nextrs_api_handlers_are_collected_without_path_check() {
let route_body = r#"
#[nextrs::api(get, responses((status = 200, body = Pong)))]
pub async fn get() -> axum::Json<Pong> { todo!() }
"#;
let tmp = setup_app_with_file_bodies(&[("api/ping", &[("route.rs", route_body)])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("__nextrs_route_0_route::get,"),
"nextrs::api handler should be in the OpenAPI paths():\n{}",
code
);
assert!(
!code.contains("compile_error!"),
"derived-path handler has nothing to validate:\n{}",
code
);
}
#[test]
fn matching_path_emits_no_compile_error() {
let route_body = r#"
#[utoipa::path(get, path = "/api/ping", responses((status = 200, body = Pong)))]
pub async fn get() -> axum::Json<Pong> { todo!() }
"#;
let tmp = setup_app_with_file_bodies(&[("api/ping", &[("route.rs", route_body)])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
!code.contains("compile_error!"),
"matching path should not error:\n{}",
code
);
}
#[test]
fn client_status_reports_per_method_inclusion() {
let route_body = r#"
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct Pong { pub ok: bool }
#[nextrs::api(get, responses((status = 200, body = Pong)))]
pub async fn get() -> Json<Pong> { Json(Pong { ok: true }) }
// Unannotated — routes, but stays out of the client.
pub async fn post() -> axum::http::StatusCode { axum::http::StatusCode::CREATED }
"#;
let tmp = setup_app_with_file_bodies(&[("api/ping", &[("route.rs", route_body)])]);
let routes = discover_routes(tmp.path());
let statuses = client_status(&routes);
let get = statuses
.iter()
.find(|s| s.method == "GET")
.expect("GET should be discovered");
assert_eq!(get.url_path, "/api/ping");
assert!(get.in_client, "annotated GET should be in the client");
let post = statuses
.iter()
.find(|s| s.method == "POST")
.expect("POST should be discovered");
assert!(!post.in_client, "unannotated POST should be excluded");
}
#[test]
fn client_summary_text_is_inspection_friendly() {
let route_body = r#"
#[nextrs::api(get, responses((status = 200, body = Pong)))]
pub async fn get() -> axum::Json<Pong> { todo!() }
pub async fn post() -> axum::http::StatusCode { axum::http::StatusCode::CREATED }
"#;
let tmp = setup_app_with_file_bodies(&[("api/ping", &[("route.rs", route_body)])]);
let routes = discover_routes(tmp.path());
let summary = client_summary_text(&routes).expect("route.rs handlers should summarize");
assert!(summary.starts_with("nextrs: typed client generated for 1/2 route.rs handler(s)"));
assert!(summary.contains("GET /api/ping client ✓"));
assert!(
summary.contains("POST /api/ping no client (add #[nextrs::api])")
);
assert_eq!(
client_summary_file_name("nextrs_routes.rs"),
"nextrs_routes.client-summary.txt"
);
}
#[test]
fn page_slug_shapes() {
assert_eq!(page_slug("/"), "index");
assert_eq!(page_slug("/todos"), "todos");
assert_eq!(page_slug("/users/{id}"), "users-_id_");
assert_eq!(page_slug("/a/b/c"), "a-b-c");
}
#[test]
fn tsx_page_emits_shell_handler() {
let tmp = setup_app(&[("todos", &["page.tsx"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains(
r#"static_page("<link rel=\"stylesheet\" href=\"/style.css\" /><div id=\"__nx_root__\"></div>"#
),
"expected stylesheet before shell mount div:\n{}",
code
);
assert!(
code.contains(r#"src=\"/dist/__app_shell__.js\""#),
"expected module script to boot the shared app-shell bundle:\n{}",
code
);
assert!(!code.contains("compile_error!"), "{}", code);
}
#[test]
fn tsx_loading_shell_includes_stylesheet_before_mount() {
let tmp = setup_app(&[("", &["loading.tsx"]), ("todos", &["page.tsx", "props.rs"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains(
r#"static_loading("<link rel=\"stylesheet\" href=\"/style.css\" /><div id=\"__nx_loading_root__\"></div>"#
),
"expected stylesheet before loading mount div:\n{}",
code
);
}
#[test]
fn tsx_beside_rs_or_html_page_is_a_conflict() {
for other in ["page.rs", "page.html"] {
let tmp = setup_app(&[("todos", &["page.tsx", other])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("compile_error!") && code.contains("one rendering model per segment"),
"expected tsx/{} conflict:\n{}",
other,
code
);
}
}
#[test]
fn tsx_page_with_props_awaits_props_and_streams_seeds() {
let tmp = setup_app(&[("todos", &["page.tsx", "props.rs"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("mod __nextrs_route_0_props;"),
"expected props mod declaration:\n{}",
code
);
assert!(
code.contains("__nextrs_route_0_props::props(req).await"),
"expected props await in shell handler:\n{}",
code
);
assert!(
code.contains("seeds.to_script_tag()"),
"expected seeds injection:\n{}",
code
);
let handler = code
.split("page: Some(")
.nth(1)
.expect("page handler emitted");
assert!(
handler.find("to_script_tag").unwrap() < handler.find("__nx_root__").unwrap(),
"seeds must precede the mount div:\n{}",
handler
);
assert!(!code.contains("compile_error!"), "{}", code);
}
#[test]
fn prefetch_slot_emitted_for_prefetch_backed_routes() {
let tmp = setup_app(&[("todos", &["page.tsx", "prefetch.rs"])]);
let code = generate_code(&discover_routes(tmp.path()));
assert!(
code.contains("prefetch: Some(Box::new(|req| Box::pin(__nextrs_route_0_props::prefetch(req))))"),
"{code}"
);
let tmp = setup_app(&[("todos/[id]", &["page.tsx", "prefetch.rs"])]);
let code = generate_code(&discover_routes(tmp.path()));
assert!(code.contains("prefetch: Some(Box::new(|req| Box::pin(async move {"), "{code}");
assert!(code.contains("__nextrs_route_0_props::prefetch(req, params).await"), "{code}");
let tmp = setup_app(&[("todos", &["page.tsx", "props.rs"])]);
let code = generate_code(&discover_routes(tmp.path()));
assert!(code.contains("Box::pin(__nextrs_route_0_props::props(req))"), "{code}");
let tmp = setup_app(&[("about", &["page.tsx"])]);
let code = generate_code(&discover_routes(tmp.path()));
assert!(code.contains("prefetch: None,"), "{code}");
}
#[test]
fn dynamic_tsx_page_with_props_gets_params() {
let tmp = setup_app(&[("source/[id]", &["page.tsx", "props.rs"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("::nextrs::params::extract_params(req).await"),
"expected params extraction:\n{}",
code
);
assert!(
code.contains("__nextrs_route_0_props::props(req, params.clone()).await"),
"expected props(req, params) call:\n{}",
code
);
let handler = code
.split("page: Some(")
.nth(1)
.expect("page handler emitted");
let params_at = handler.find("params.to_script_tag").unwrap();
let seeds_at = handler.find("seeds.to_script_tag").unwrap();
let root_at = handler.find("__nx_root__").unwrap();
assert!(params_at < seeds_at && seeds_at < root_at, "{}", handler);
assert!(!code.contains("compile_error!"), "{}", code);
}
#[test]
fn dynamic_tsx_page_without_props_still_gets_params() {
let tmp = setup_app(&[("source/[id]", &["page.tsx"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("::nextrs::params::extract_params(req).await"),
"expected params extraction:\n{}",
code
);
assert!(
!code.contains("static_page"),
"dynamic tsx page can't be a static shell:\n{}",
code
);
assert!(!code.contains("compile_error!"), "{}", code);
}
#[test]
fn static_tsx_page_without_props_stays_static() {
let tmp = setup_app(&[("about", &["page.tsx"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(code.contains("static_page"), "{}", code);
assert!(!code.contains("extract_params"), "{}", code);
}
#[test]
fn loading_tsx_is_inherited_by_props_backed_tsx_pages() {
let tmp = setup_app(&[
("", &["loading.tsx"]),
("dashboard", &["page.tsx", "props.rs"]),
(
"dashboard/reports",
&["page.tsx", "props.rs", "loading.tsx"],
),
]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
let dashboard = code
.split("\"/dashboard\".to_string()")
.nth(1)
.expect("dashboard route emitted");
assert!(
dashboard.contains(r#"import(\"/dist/index.loading.js\")"#),
"dashboard should inherit root loading.tsx:\n{}",
dashboard
);
let reports = code
.split("\"/dashboard/reports\".to_string()")
.nth(1)
.expect("reports route emitted");
assert!(
reports.contains(r#"import(\"/dist/dashboard-reports.loading.js\")"#),
"nearest loading.tsx should win:\n{}",
reports
);
}
#[test]
fn loading_tsx_without_props_backed_descendant_is_detected() {
let tmp = setup_app(&[("", &["loading.tsx"]), ("about", &["page.tsx"])]);
let routes = discover_routes(tmp.path());
let root = routes.iter().find(|route| route.url_path == "/").unwrap();
assert!(!loading_tsx_applies_to_any_props_route(&routes, root));
}
#[test]
fn tsx_layout_or_loading_beside_legacy_files_is_a_conflict() {
for (slot, legacy) in [
("layout", "layout.rs"),
("layout", "layout.html"),
("loading", "loading.rs"),
("loading", "loading.html"),
] {
let tsx = format!("{slot}.tsx");
let tmp = setup_app(&[("dashboard", &[tsx.as_str(), legacy])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("compile_error!") && code.contains("cannot coexist"),
"expected {tsx}/{legacy} conflict:\n{}",
code
);
}
}
#[test]
fn props_without_tsx_page_is_a_conflict() {
let tmp = setup_app(&[("todos", &["page.rs", "props.rs"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("compile_error!") && code.contains("requires a page.tsx sibling"),
"{}",
code
);
}
#[test]
fn not_found_rs_emits_add_not_found_and_mod() {
let tmp = setup_app_with_file_bodies(&[(
"admin",
&[(
"not-found.rs",
"use axum::body::Body;\nuse http::Request;\npub async fn render(_req: Request<Body>) -> String { \"missing\".into() }\n",
)],
)]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("mod __nextrs_route_0_notfound;"),
"expected not-found mod declaration:\n{}",
code
);
assert!(
code.contains(
"registry.add_not_found(\"/admin\".to_string(), Box::new(|req| Box::pin(__nextrs_route_0_notfound::render(req))));"
),
"expected add_not_found wiring:\n{}",
code
);
assert!(!code.contains("compile_error!"), "{}", code);
}
#[test]
fn not_found_html_emits_static_page_include() {
let tmp = setup_app(&[("", &["not-found.html"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("registry.add_not_found(\"/\".to_string(), ::nextrs::conventions::static_page(include_str!("),
"expected html not-found via static_page:\n{}",
code
);
}
#[test]
fn not_found_tsx_emits_shell_with_not_found_slug() {
let tmp = setup_app(&[("admin", &["not-found.tsx"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains(r#"src=\"/dist/admin-not-found.js\""#),
"expected not-found bundle slug in shell:\n{}",
code
);
assert!(
code.contains("registry.add_not_found(\"/admin\".to_string(), ::nextrs::conventions::static_page("),
"expected tsx shell wired via add_not_found:\n{}",
code
);
assert!(!code.contains("compile_error!"), "{}", code);
}
#[test]
fn not_found_tsx_beside_rs_is_a_conflict() {
let tmp = setup_app(&[("admin", &["not-found.tsx", "not-found.rs"])]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(
code.contains("compile_error!") && code.contains("not-found conflict"),
"expected not-found tsx/rs conflict:\n{}",
code
);
}
#[test]
fn not_found_slug_shapes() {
assert_eq!(not_found_slug("/"), "not-found");
assert_eq!(not_found_slug("/admin"), "admin-not-found");
assert_eq!(not_found_slug("/a/b"), "a-b-not-found");
}
#[test]
fn url_snake_shapes() {
assert_eq!(url_snake("/api/todos"), "api_todos");
assert_eq!(url_snake("/users/{id}"), "users_by_id");
assert_eq!(url_snake("/"), "root");
assert_eq!(url_snake("/my-thing"), "my_thing");
}
#[test]
fn seed_eligibility_mirrors_macro() {
assert!(get_is_seed_eligible(
"pub async fn get(Query(f): Query<TodosFilter>) -> Json<Vec<Todo>> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get() -> Json<PingResponse> { todo!() }"
));
assert!(!get_is_seed_eligible(
"pub async fn get() -> impl IntoResponse { todo!() }"
));
assert!(!get_is_seed_eligible(
"pub async fn get(Query(f): Query<F>, headers: HeaderMap) -> Json<X> { todo!() }"
));
assert!(!get_is_seed_eligible(
"pub async fn get(req: Request<Body>) -> Json<X> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get(Path(id): Path<i64>) -> Json<Vec<Page>> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get(Path(id): Path<i64>, Query(f): Query<F>) -> Json<X> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get(Query(f): Query<F>, Path(id): Path<i64>) -> Json<X> { todo!() }"
));
assert!(!get_is_seed_eligible(
"pub async fn get(Path(id): Path<i64>, headers: HeaderMap) -> Json<X> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get() -> Result<Json<Vec<Todo>>, ApiError> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get(Path(id): Path<u64>) -> Result<Json<TodoDetail>, StatusCode> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get() -> axum::Json<X> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get() -> Result<axum::Json<X>, E> { todo!() }"
));
assert!(get_is_seed_eligible(
"pub async fn get(\n Path(id): Path<u64>,\n Query(q): Query<DetailQuery>,\n) -> Result<Json<TodoDetail>, StatusCode> { todo!() }"
));
assert!(!get_is_seed_eligible(
"pub async fn get() -> Result<StatusCode, Json<E>> { todo!() }"
));
assert!(!get_is_seed_eligible(
"pub async fn get() -> JsonLines<X> { todo!() }"
));
assert!(!get_is_seed_eligible(
"pub async fn get() -> impl IntoResponse { todo!() }"
));
}
#[test]
fn page_and_route_get_conflict_emits_compile_error() {
let route_body = r#"
use axum::body::Body;
use axum::response::IntoResponse;
use http::{Request, StatusCode};
pub async fn get(_req: Request<Body>) -> impl IntoResponse { StatusCode::OK }
"#;
let tmp = setup_app_with_file_bodies(&[(
"reviews",
&[
("page.rs", "pub fn render() -> String { String::new() }"),
("route.rs", route_body),
],
)]);
let routes = discover_routes(tmp.path());
let code = generate_code(&routes);
assert!(code.contains("compile_error!"));
assert!(code.contains("page owns GET"));
}
}