// Router request/route parameter types and extraction
///|
/// Mars app type alias
pub type App = @mars.Server
///|
/// Mars context type alias
pub type Ctx = @mars.Context
///|
/// Route parameters extracted from URL
pub struct RouteParams {
/// Path parameters (e.g., {:id} -> [("id", "123")])
params : Array[(String, String)]
/// Query parameters
query : Array[(String, String)]
/// Full path
path : String
/// Whether this is a fragment request (CSR navigation)
is_fragment : Bool
}
///|
/// Create empty route params
pub fn RouteParams::empty() -> RouteParams {
{ params: [], query: [], path: "", is_fragment: false }
}
///|
/// Get path parameter by name
pub fn RouteParams::get_param(self : RouteParams, key : String) -> String? {
self.params
.iter()
.find_first(fn(pair) { pair.0 == key })
.map(fn(pair) { pair.1 })
}
///|
/// Get query parameter by name
pub fn RouteParams::get_query(self : RouteParams, key : String) -> String? {
self.query
.iter()
.find_first(fn(pair) { pair.0 == key })
.map(fn(pair) { pair.1 })
}
///|
/// Extract a required path parameter, returning ApiResponse error if missing
pub fn RouteParams::require(
self : RouteParams,
name : String,
) -> Result[String, ApiResponse] {
match self.get_param(name) {
Some(v) => Ok(v)
None => Err(ApiResponse::Error("Missing required parameter: " + name, 400))
}
}
///|
/// Extract an optional path parameter with default value
pub fn RouteParams::optional(
self : RouteParams,
name : String,
default : String,
) -> String {
self.get_param(name).unwrap_or(default)
}
///|
/// Extract and parse an integer path parameter
pub fn RouteParams::require_int(
self : RouteParams,
name : String,
) -> Result[Int, ApiResponse] {
match self.get_param(name) {
Some(v) =>
Ok(@string.parse_int(v)) catch {
_ => Err(ApiResponse::Error("Invalid integer parameter: " + name, 400))
}
None => Err(ApiResponse::Error("Missing required parameter: " + name, 400))
}
}
///|
/// Extract a required query parameter
pub fn RouteParams::require_query(
self : RouteParams,
name : String,
) -> Result[String, ApiResponse] {
match self.get_query(name) {
Some(v) => Ok(v)
None =>
Err(ApiResponse::Error("Missing required query parameter: " + name, 400))
}
}
///|
/// Extract an optional query parameter with default value
pub fn RouteParams::optional_query(
self : RouteParams,
name : String,
default : String,
) -> String {
self.get_query(name).unwrap_or(default)
}
///|
/// Props passed to page components
pub struct PageProps {
/// Request context (Hono Context)
/// Internal context storage. Use PageProps methods instead of direct access.
/// Request-specific accessors return None during ISR renders.
priv ctx : Ctx
/// Route parameters (path params, query params)
params : RouteParams
/// Whether this is a fragment request (CSR navigation)
/// When true, page should return content without layout wrapper
is_fragment : Bool
/// Whether this render is for ISR caching.
/// When true, the rendered HTML will be shared across all users.
/// Do NOT access ctx headers (Authorization, Cookie, etc.) in this mode.
is_isr_render : Bool
}
///|
/// Create PageProps from context and pre-extracted params
/// Use this when params are already extracted by the router
pub fn PageProps::from_ctx_with_params(
ctx : Ctx,
params : RouteParams,
) -> PageProps {
{ ctx, params, is_fragment: params.is_fragment, is_isr_render: false }
}
///|
/// Get path parameter by name (convenience method)
pub fn PageProps::get_param(self : PageProps, key : String) -> String? {
self.params.get_param(key)
}
///|
/// Get query parameter by name (convenience method)
pub fn PageProps::get_query(self : PageProps, key : String) -> String? {
self.params.get_query(key)
}
///|
/// Get pathname group from URLPatternResult (convenience method)
pub fn PageProps::get_pathname_group(
self : PageProps,
name : String,
) -> String? {
self.params.get_pathname_group(name)
}
///|
/// Get search group from URLPatternResult (convenience method)
pub fn PageProps::get_search_group(self : PageProps, name : String) -> String? {
self.params.get_search_group(name)
}
///|
/// Get the full path
pub fn PageProps::path(self : PageProps) -> String {
self.params.path
}
///|
/// Get request header.
/// Returns None during ISR render to avoid leaking request-specific data.
pub fn PageProps::header(self : PageProps, name : String) -> String? {
if self.is_isr_render {
None
} else {
self.ctx.header(name)
}
}
///|
/// Get raw request header.
/// Returns None during ISR render to avoid leaking request-specific data.
pub fn PageProps::raw_header(self : PageProps, name : String) -> String? {
if self.is_isr_render {
None
} else {
self.ctx.raw_header(name)
}
}
///|
/// Get cookie value by name.
/// Returns None during ISR render to avoid leaking request-specific data.
pub fn PageProps::cookie(self : PageProps, name : String) -> String? {
if self.is_isr_render {
None
} else {
self.ctx.cookie(name)
}
}
///|
/// Parse request body as JSON.
/// Returns None during ISR render to avoid caching request-specific bodies.
pub async fn PageProps::body_json(self : PageProps) -> Json? {
if self.is_isr_render {
None
} else {
self.ctx.body_json()
}
}
///|
/// Parse request body as a typed struct via FromJson.
/// Returns Err(ApiResponse) on parse failure (400 Bad Request).
pub async fn[T : @json.FromJson] PageProps::body_as(
self : PageProps,
) -> Result[T, ApiResponse] {
match self.body_json() {
None => Err(bad_request("Missing or unreadable request body"))
Some(json) =>
Ok(@json.from_json(json)) catch {
e => Err(bad_request("Invalid request body: " + e.to_string()))
}
}
}
///|
/// Set response header.
pub fn PageProps::set_header(
self : PageProps,
name : String,
value : String,
) -> Unit {
if self.is_isr_render {
()
} else {
self.ctx.set_header(name, value)
}
}
///|
/// Send a text response.
/// No-op during ISR render to keep shared HTML generation side-effect free.
pub fn PageProps::text(self : PageProps, body : String, status? : Int) -> Unit {
if self.is_isr_render {
()
} else {
match status {
Some(code) => self.ctx.text(body, status=code)
None => self.ctx.text(body)
}
}
}
///|
/// Send an HTML response.
/// No-op during ISR render to keep shared HTML generation side-effect free.
pub fn PageProps::html(self : PageProps, body : String, status? : Int) -> Unit {
if self.is_isr_render {
()
} else {
match status {
Some(code) => self.ctx.html(body, status=code)
None => self.ctx.html(body)
}
}
}
///|
/// Set response status code.
/// No-op during ISR render to keep shared HTML generation side-effect free.
pub fn PageProps::set_status(self : PageProps, status : Int) -> Unit {
if self.is_isr_render {
()
} else {
self.ctx.set_status(status)
}
}
///|
/// Extract route parameters from Mars context
fn extract_route_params(
c : Ctx,
is_fragment? : Bool = false,
param_names? : Array[String] = [],
) -> RouteParams {
let raw_path = c.path()
// Use Mars public API (`Context::param`) to extract known route params.
let params = param_names.filter_map(fn(name) {
c.param(name).map(fn(value) { (name, value) })
})
// Get query params and strip query from path
let (path, query) = match raw_path.find("?") {
Some(_) => (strip_query_string(raw_path), c.queries().to_array())
None => (raw_path, [])
}
{ params, query, path, is_fragment }
}
///|
/// Remove query string from path (everything after first '?')
fn strip_query_string(path : String) -> String {
match path.find("?") {
Some(idx) => path[:idx].to_owned()
None => path
}
}
///|
/// Extract parameter names from a route path pattern for use with Mars/Hono param().
/// Handles colon params (`:id`, `:version{[0-9]+}`), bracket params (`[id]`),
/// catch-all params (`[...slug]`), and optional catch-all params (`[[...slug]]`).
/// Deduplicates names and strips regex suffixes from colon params.
///
/// Note: This is distinct from `extract_param_names` in `sol_routes.mbt`, which
/// excludes catch-all params and does not handle regex suffixes. Use this function
/// when registering legacy routes via Mars/Hono; use `extract_param_names` for
/// the newer SolRoutes compilation pipeline.
fn extract_route_param_names(path : String) -> Array[String] {
let names : Array[String] = []
path
.split("/")
.filter_map(fn(seg) { segment_to_param_name(seg.to_owned()) })
.each(fn(name) {
if !name.is_empty() && !names.contains(name) {
names.push(name)
}
})
names
}
///|
fn segment_to_param_name(segment : String) -> String? {
if segment.has_prefix(":") && segment.length() > 1 {
let raw = segment[1:].to_owned()
let end = find_route_param_end(raw)
let name = raw[:end].to_owned()
if name.is_empty() {
None
} else {
Some(name)
}
} else if segment.has_prefix("[[...") &&
segment.has_suffix("]]") &&
segment.length() > 7 {
let name = segment[5:segment.length() - 2].to_owned()
if name.is_empty() {
None
} else {
Some(name)
}
} else if segment.has_prefix("[...") &&
segment.has_suffix("]") &&
segment.length() > 5 {
let name = segment[4:segment.length() - 1].to_owned()
if name.is_empty() {
None
} else {
Some(name)
}
} else if segment.has_prefix("[") &&
segment.has_suffix("]") &&
segment.length() > 2 {
let name = segment[1:segment.length() - 1].to_owned()
if name.is_empty() {
None
} else {
Some(name)
}
} else {
None
}
}
///|
fn find_route_param_end(raw : String) -> Int {
for i in 0..