diff --git a/Cargo.toml b/Cargo.toml index 106a958..4b47ff5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/rustapi-toon", "crates/rustapi-ws", "crates/rustapi-view", + "crates/rustapi-ssr", "crates/rustapi-testing", "crates/rustapi-jobs", "crates/cargo-rustapi", @@ -108,6 +109,6 @@ rustapi-extras = { path = "crates/rustapi-extras", version = "0.1.14" } rustapi-toon = { path = "crates/rustapi-toon", version = "0.1.14" } rustapi-ws = { path = "crates/rustapi-ws", version = "0.1.14" } rustapi-view = { path = "crates/rustapi-view", version = "0.1.14" } +rustapi-ssr = { path = "crates/rustapi-ssr", version = "0.1.14" } rustapi-testing = { path = "crates/rustapi-testing", version = "0.1.14" } rustapi-jobs = { path = "crates/rustapi-jobs", version = "0.1.14" } - diff --git a/crates/rustapi-ssr/Cargo.toml b/crates/rustapi-ssr/Cargo.toml new file mode 100644 index 0000000..4a5c7ec --- /dev/null +++ b/crates/rustapi-ssr/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rustapi-ssr" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +rust-version = { workspace = true } + +[lib] +path = "src/lib.rs" + +build = "build.rs" + +[dependencies] +inventory = { workspace = true } +matchit = { workspace = true } +thiserror = { workspace = true } + +[features] +default = [] + +[package.metadata] +rustapi = true diff --git a/crates/rustapi-ssr/build.rs b/crates/rustapi-ssr/build.rs new file mode 100644 index 0000000..fe74028 --- /dev/null +++ b/crates/rustapi-ssr/build.rs @@ -0,0 +1,184 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +struct RouteEntry { + pattern: String, + normalized: String, + source: String, +} + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let app_dir = resolve_app_dir(&manifest_dir); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + let dest_path = out_dir.join("app_router_routes.rs"); + + if !app_dir.exists() { + write_routes(&dest_path, &[]); + emit_rerun_if_changed(&app_dir); + return; + } + + let mut routes = Vec::new(); + collect_routes(&app_dir, &app_dir, &mut routes); + + validate_routes(&routes); + write_routes(&dest_path, &routes); + emit_rerun_if_changed(&app_dir); +} + +fn resolve_app_dir(manifest_dir: &Path) -> PathBuf { + if let Ok(env_dir) = env::var("RUSTAPI_APP_DIR") { + return PathBuf::from(env_dir); + } + + let workspace_root = manifest_dir + .parent() + .and_then(|parent| parent.parent()) + .unwrap_or(manifest_dir); + let workspace_app = workspace_root.join("app"); + if workspace_app.exists() { + return workspace_app; + } + + manifest_dir.join("app") +} + +fn collect_routes(app_dir: &Path, current: &Path, routes: &mut Vec) { + let entries = match fs::read_dir(current) { + Ok(entries) => entries, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_routes(app_dir, &path, routes); + continue; + } + + if path.file_name().and_then(|s| s.to_str()) != Some("page.rs") { + continue; + } + + let relative = match path.strip_prefix(app_dir) { + Ok(path) => path, + Err(_) => continue, + }; + + let segments: Vec = relative + .components() + .filter_map(|component| component.as_os_str().to_str().map(|s| s.to_string())) + .collect(); + + if segments.is_empty() { + continue; + } + + let mut route_segments = Vec::new(); + for segment in &segments[..segments.len() - 1] { + if let Some(stripped) = segment.strip_prefix('(').and_then(|s| s.strip_suffix(')')) { + if stripped.is_empty() { + panic!("Invalid route group segment '()' in {}", path.display()); + } + continue; + } + + if let Some(param) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + if param.is_empty() { + panic!("Invalid dynamic segment '[]' in {}", path.display()); + } + if !param + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + panic!("Invalid dynamic segment '[{}]' in {}", param, path.display()); + } + route_segments.push(format!("{{{}}}", param)); + continue; + } + + if segment.is_empty() { + panic!("Empty path segment in {}", path.display()); + } + + route_segments.push(segment.to_string()); + } + + let pattern = if route_segments.is_empty() { + "/".to_string() + } else { + format!("/{}", route_segments.join("/")) + }; + + let normalized = if route_segments.is_empty() { + "/".to_string() + } else { + let mut normalized_segments = Vec::new(); + for seg in &route_segments { + if seg.starts_with('{') && seg.ends_with('}') { + normalized_segments.push("{}"); + } else { + normalized_segments.push(seg); + } + } + format!("/{}", normalized_segments.join("/")) + }; + + let source = format!("app/{}", segments.join("/")); + routes.push(RouteEntry { + pattern, + normalized, + source, + }); + } +} + +fn validate_routes(routes: &[RouteEntry]) { + let mut seen = std::collections::HashMap::new(); + for route in routes { + if let Some(existing) = seen.insert(route.normalized.clone(), route) { + panic!( + "Route conflict: '{}' from {} conflicts with {}", + route.pattern, existing.source, route.source + ); + } + } +} + +fn write_routes(dest_path: &Path, routes: &[RouteEntry]) { + let mut output = String::new(); + output.push_str("// @generated by rustapi-ssr build script.\n"); + + for route in routes { + output.push_str(&format!( + "::inventory::submit! {{ ::rustapi_ssr::app_router::PageRoute {{ pattern: \"{}\", source: \"{}\" }} }}\n", + route.pattern, route.source + )); + } + + fs::write(dest_path, output).expect("failed to write app router routes"); +} + +fn emit_rerun_if_changed(app_dir: &Path) { + println!("cargo:rerun-if-changed={}", app_dir.display()); + let mut stack = vec![app_dir.to_path_buf()]; + while let Some(dir) = stack.pop() { + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + println!("cargo:rerun-if-changed={}", path.display()); + } + } + } +} diff --git a/crates/rustapi-ssr/src/app_router/mod.rs b/crates/rustapi-ssr/src/app_router/mod.rs new file mode 100644 index 0000000..4199e43 --- /dev/null +++ b/crates/rustapi-ssr/src/app_router/mod.rs @@ -0,0 +1,63 @@ +use std::sync::OnceLock; + +use matchit::Router; +use thiserror::Error; + +#[derive(Debug, Clone, Copy)] +pub struct PageRoute { + pub pattern: &'static str, + pub source: &'static str, +} + +inventory::collect!(PageRoute); + +include!(concat!(env!("OUT_DIR"), "/app_router_routes.rs")); + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error("route registry build failed: {0}")] + Router(#[from] matchit::InsertError), +} + +#[derive(Debug)] +pub struct MatchedRoute<'a> { + pub route: &'a PageRoute, + pub params: Vec<(&'a str, String)>, +} + +#[derive(Debug)] +pub struct RouteRegistry { + router: Router<&'static PageRoute>, +} + +impl RouteRegistry { + pub fn new() -> Result { + let mut router = Router::new(); + for route in inventory::iter:: { + router.insert(route.pattern, route)?; + } + Ok(Self { router }) + } + + pub fn match_route(&self, path: &str) -> Option> { + let matched = self.router.at(path).ok()?; + let params = matched + .params + .iter() + .map(|(key, value)| (key, value.to_string())) + .collect(); + Some(MatchedRoute { + route: matched.value, + params, + }) + } +} + +pub fn registry() -> Result<&'static RouteRegistry, RegistryError> { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_try_init(RouteRegistry::new) +} + +pub fn routes() -> impl Iterator { + inventory::iter:: +} diff --git a/crates/rustapi-ssr/src/lib.rs b/crates/rustapi-ssr/src/lib.rs new file mode 100644 index 0000000..40e6368 --- /dev/null +++ b/crates/rustapi-ssr/src/lib.rs @@ -0,0 +1 @@ +pub mod app_router;