Thin Mars wrapper for file-based routing, SSR, and asset loading
// moon.mod.json
{ "deps": { "mizchi/sol": "0.21.1", "mizchi/luna": "0.21.0" } }moon install mizchi/sol/cmd/sol # → $MOON_HOME/bin/sol
# or via npm
pnpm add -g @luna_ui/sol # npm wrapper 0.20.0cd sol/examples/sol_app
pnpm install
sol devsol new myapp --user mizchi --cloudflare
cd myapp
pnpm install
pnpm devimport solApp from "./.sol/prod/server/main.js";
import apiWorker from "./worker-api.mjs";
export default {
fetch(request, env, ctx) {
const path = new URL(request.url).pathname;
return path.startsWith("/api/")
? apiWorker.fetch(request, env, ctx)
: solApp.fetch(request, env, ctx);
},
};just -f ../astra/justfile sync-luna-assets ../luna.mbt# Create new project
sol new myapp --user yourname
cd myapp
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build and deploy preflight (Cloudflare Workers default)
sol build
sol deploy --dry-runexport default {
islands: ["app/client"],
routes: "app/server",
output: "app/__gen__",
runtime: "node",
// Defaults preserve the generated Sol pipeline.
serverEntry: "auto", // "auto" | "generated" | "user"
clientBundle: "auto", // "auto" | "external"
client_auto_exports: false,
wasmEntryPoints: [
{
id: "users_show",
route: "/users/:id",
source: "app/entries/users_show.mbtx",
runtime: "wagi", // "wagi" | "wasi-cli" | "component"
method: ["GET"],
},
],
}let client_manifest =
#|{
#| "app/client/counter.ts": {
#| "file": "assets/counter-abc123.js",
#| "imports": ["assets/runtime-def456.js"]
#| }
#|}
pub fn config() -> @sol.RouterConfig {
@sol.RouterConfig::default()
.with_client_manifest_json(client_manifest, base_url="/static/")
}@sol.island(
@types.counter_at(
"/static/assets/counter-abc123.js",
{ initial_count: 42 },
),
[counter_ssr()],
)export default {
clientBundle: "external",
contractTs: [
{
input: "app/client/props.ts",
props: "CounterProps",
package: "app/client",
clientUrl: "/static/assets/counter-abc123.js",
},
],
}{
"routes": [
{
"path": "/api/items/:id",
"query": [
{ "name": "token" },
{ "name": "include", "optional": true }
]
}
],
"actions": [
{
"id": "submit-contact",
"request": {
"name": "SubmitContactRequest",
"fields": [{ "name": "email", "type": "String" }]
},
"response": {
"name": "SubmitContactResponse",
"fields": [{ "name": "ok", "type": "Bool" }]
}
}
],
"apis": [
{
"method": "POST",
"path": "/api/items/:id",
"request": {
"name": "CreateItemRequest",
"fields": [{ "name": "title", "type": "String" }]
},
"response": {
"name": "ItemResponse",
"fields": [{ "name": "id", "type": "String" }]
}
}
]
}{
"$schema": "./schemas/sol.config.schema.json"
}myapp/
├── moon.mod.json # MoonBit module definition
├── package.json # npm package definition
├── sol.config.json # Sol config file
├── worker.entry.mjs # Cloudflare starter compose point (--cloudflare)
├── wrangler.toml # Cloudflare Workers config (--cloudflare)
├── app/
│ ├── server/ # Server components
│ │ ├── moon.pkg
│ │ └── routes.mbt # routes() + config() + page functions
│ ├── client/ # Client components (Islands)
│ │ ├── moon.pkg
│ │ ├── counter.mbt # render + hydrate functions
│ │ └── api_tools.mbt # copy/status/format starter controls
│ └── __gen__/ # Auto-generated (sol generate)
│ ├── client/ # Client exports
│ └── server/ # Server entry point
└── static/
└── loader.js # Island loadersol new myapp --user mizchi # Create mizchi/myapp package
sol new myapp --user mizchi --cloudflare
sol new myapp --user mizchi --dev # Use local luna path (for development)sol dev # Default port 7777
sol dev --port 8080 # Specify port
sol dev --clean # Clear cache and buildsol build # JS target (default)
sol build --target wasm # WASM target
sol build --skip-bundle # Skip rolldown
sol build --skip-generate # Skip generation
sol build --clean # Clear cache and build
sol build --wasm-entrypoints --runtime wagi --emit-spin-fragmentsol serve # Default port 7777
sol serve --port 8080 # Specify portsol deploy # Dry-run guidance
sol deploy --provider cloudflare-workers --project my-worker
sol deploy --execute # Execute wrangler commandsol doctor # Print warnings/errors
sol doctor --strict # Treat warnings as failuresNote: Usually sol dev and sol build call this internally, so explicit execution is not needed.
sol generate # Use sol.config.ts or sol.config.json (default: prod)
sol generate --mode dev # Development mode (outputs to .sol/dev/)
sol generate --mode prod # Production mode (outputs to .sol/prod/)sol clean # Delete .sol/, app/__gen__/, _build/// app/server/routes.mbt
pub fn routes() -> Array[@router.SolRoutes] {
[
// Page route
@router.SolRoutes::Page(
path="/",
handler=@router.PageHandler(home_page),
title="Home",
meta=[],
revalidate=None,
cache=None,
),
// API route (GET)
@router.SolRoutes::Get(
path="/api/health",
handler=@router.ApiHandler(api_health),
),
// API route (POST)
@router.SolRoutes::Post(
path="/api/submit",
handler=@router.ApiHandler(api_submit),
),
// Nested layout
@router.SolRoutes::Layout(
segment="/admin",
layout=admin_layout,
children=[
@router.SolRoutes::Page(path="/", handler=@router.PageHandler(admin_dashboard), title="Admin", meta=[], revalidate=None, cache=None),
@router.SolRoutes::Page(path="/users", handler=@router.PageHandler(admin_users), title="Users", meta=[], revalidate=None, cache=None),
],
),
// Apply middleware
@router.SolRoutes::WithMiddleware(
middleware=[@middleware.cors(), @middleware.logger()],
children=[
@router.SolRoutes::Get(path="/api/data", handler=@router.ApiHandler(api_data)),
],
),
]
}
pub fn config() -> @router.RouterConfig {
@router.RouterConfig::default()
.with_default_head(head())
.with_loader_url("/static/loader.js")| Variant | Description |
|---|---|
| Page | Page route (HTML response) |
| Get | GET API route (JSON response) |
| Post | POST API route (JSON response) |
| RawGet / RawPost / RawPut / RawDelete / RawPatch | Raw Web Response route |
| Layout | Nested layout group |
| WithMiddleware | Route group with middleware applied |
import solApp from "./.sol/prod/server/main.js";
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) {
return apiFetch(request, env, ctx);
}
return solApp.fetch(request, env, ctx);
},
};// Middleware composition
let middleware = @middleware.logger()
.then(@middleware.cors())
.then(@middleware.security_headers())
// Apply to routes
@router.SolRoutes::WithMiddleware(
middleware=[middleware],
children=[...],
)| Middleware | Description |
|---|---|
| logger() | Request logging |
| cors() | CORS headers |
| csrf() | CSRF protection |
| security_headers() | Security headers |
| nosniff() | X-Content-Type-Options |
| frame_options(value) | X-Frame-Options |
@middleware.cors_with_config(
@middleware.CorsConfig::default()
.with_origin_single("https://example.com")
.with_methods(["GET", "POST"])
.with_credentials()
)@middleware.security_headers_with_config(
@middleware.SecurityHeadersConfig::default()
.with_csp("default-src 'self'")
.with_frame_options("DENY")
)fn require_admin() -> @middleware.Middleware {
@middleware.Middleware(@mars.Handler(async fn(ctx) {
match ctx.header("Authorization") {
Some(token) if token.has_prefix("Bearer ") => ()
_ => ctx.json({ "error": "unauthorized" }.to_json(), status=401)
}
}))
}
pub fn routes() -> Array[@sol.SolRoutes] {
[
@sol.route("/", home),
@sol.with_mw([require_admin()], [
@sol.route("/admin", admin_page),
@sol.api_post("/api/items", create_item),
]),
]
}// Sequential execution (m1 → m2)
let combined = @middleware.then_(m1, m2)
// or
let combined = m1.then(m2)
// Compose from array
let pipeline = @middleware.pipeline([m1, m2, m3])struct SubmitRequest {
value : String
} derive(FromJson)
struct SubmitResponse {
success : Bool
} derive(ToJson)
// Define action handler
let submit_handler = @action.ActionHandler(async fn(ctx) {
let req : SubmitRequest = match ctx.decode_json() {
Some(req) => req
None => return @action.ActionResult::bad_request("Invalid JSON payload")
}
// ... processing with req
@action.ActionResult::json(SubmitResponse::{ success: true })
})
// Register to registry
pub fn action_registry() -> @action.ActionRegistry {
@action.ActionRegistry::new(allowed_origins=["http://localhost:7777"])
.register(@action.ActionDef::from_key(@types.action_submit(), submit_handler))
}| Type | Description |
|---|---|
| Success(data) | Success, returns JSON data |
| Redirect(url) | Client-side redirect (returns JSON with redirect instruction) |
| HttpRedirect(url) | HTTP redirect (returns 302 with Location header) |
| ClientError(status, msg) | Client error (4xx) |
| ServerError(msg) | Server error (5xx) |
// app/client/counter.mbt
pub fn counter(count : @signal.Signal[Int]) -> @luna.Node[CounterAction] {
div(class="counter", [
span(class="count-display", [text_of(count)]),
button(onclick=@luna.action(Increment), [text("+")]),
button(onclick=@luna.action(Decrement), [text("-")]),
])
}// Auto-generated: app/__gen__/types/types.mbt
pub struct CounterProps { initial_count : Int } derive(ToJson, FromJson)
pub fn counter(props : CounterProps, trigger~ : @luna.Trigger) -> @luna.ComponentRef[CounterProps]// app/server/home.mbt
let counter_props : @types.CounterProps = { initial_count: 42 }
// Type-safe island embedding
@sol.island(
@types.counter(counter_props),
[div([button([text("Count: 42")])])], // SSR fallback children
)@server_dom.client(
@types.counter(counter_props),
[div([text("Loading...")])],
)@sol.island_raw("counter", "/static/counter.js", props_json, children)| Trigger | Description |
|---|---|
| Load | Immediately on page load |
| Idle | On requestIdleCallback |
| Visible | On IntersectionObserver detection |
| Media(query) | On media query match |
| None | Manual trigger |
<script>
window.__LUNA_ALLOWED_HOSTS__ = [
"127.0.0.1:3456",
"https://cdn.example.com"
];
</script>window.__LUNA_SET_ALLOWED_HOSTS__?.(["127.0.0.1"]);
window.__LUNA_SCAN__?.();sol_link(href="/about", [text("About")])@server_dom.ServerNode::async_(async fn() {
let data = fetch_data() // async function call
div([text(data)])
})pub fn config() -> @router.RouterConfig {
@router.RouterConfig::default()
.with_default_head(head())
.with_loader_url("/static/loader.js")
.with_streaming_ssr()
}Note: streaming response uses root_template when __LUNA_MAIN__ exists; otherwise it falls back to built-in page shell.
src/sol/
├── runtime.mbt # Core API
├── compiler.mbt # Rolldown bindings
├── router/ # Hono router integration
│ ├── router.mbt # Route registration
│ ├── sol_routes.mbt # SolRoutes definition
│ └── fragment.mbt # Fragment response (CSR)
├── middleware/ # Middleware system
│ ├── types.mbt # MwContext, MwRequest, MwResponse
│ ├── compose.mbt # Composition functions (then, pipeline)
│ ├── logger.mbt # Logger middleware
│ ├── cors.mbt # CORS middleware
│ ├── csrf.mbt # CSRF middleware
│ └── security_headers.mbt # Security headers
├── action/ # Server Actions
│ ├── action.mbt # ActionHandler, ActionResult
│ └── router.mbt # register_actions
└── cli/ # CLI tools
├── main.mbt # Entry point
├── new.mbt # sol new
├── dev.mbt # sol dev
├── build.mbt # sol build
├── serve.mbt # sol serve
├── generate.mbt # sol generate
└── clean.mbt # sol clean# Start development server in playground
cd examples/sol_app
npm install
just sol dev# Create new project
sol new myapp --user yourname
cd myapp
# Install dependencies
npm install
# Start development server
npm run devexport default {
islands: ["app/client"],
routes: "app/server",
output: "app/__gen__",
runtime: "node",
client_auto_exports: false,
}{
"$schema": "./schemas/sol.config.schema.json"
}myapp/
├── moon.mod.json # MoonBit module definition
├── package.json # npm package definition
├── sol.config.json # Sol config file
├── app/
│ ├── server/ # Server components
│ │ ├── moon.pkg
│ │ └── routes.mbt # routes() + config() + page functions
│ ├── client/ # Client components (Islands)
│ │ ├── moon.pkg
│ │ └── counter.mbt # render + hydrate functions
│ └── __gen__/ # Auto-generated (sol generate)
│ ├── client/ # Client exports
│ └── server/ # Server entry point
└── static/
└── loader.js # Island loadersol new myapp --user mizchi # Create mizchi/myapp package
sol new myapp --user mizchi --dev # Use local luna path (for development)sol dev # Default port 7777
sol dev --port 8080 # Specify port
sol dev --clean # Clear cache and buildsol build # JS target (default)
sol build --target wasm # WASM target
sol build --skip-bundle # Skip rolldown
sol build --skip-generate # Skip generation
sol build --clean # Clear cache and buildsol serve # Default port 7777
sol serve --port 8080 # Specify portNote: Usually sol dev and sol build call this internally, so explicit execution is not needed.
sol generate # Use sol.config.ts or sol.config.json (default: prod)
sol generate --mode dev # Development mode (outputs to .sol/dev/)
sol generate --mode prod # Production mode (outputs to .sol/prod/)sol clean # Delete .sol/, app/__gen__/, _build/// app/server/routes.mbt
pub fn routes() -> Array[@router.SolRoutes] {
[
// Page route
@router.SolRoutes::Page(
path="/",
handler=@router.PageHandler(home_page),
title="Home",
meta=[],
revalidate=None,
cache=None,
),
// API route (GET)
@router.SolRoutes::Get(
path="/api/health",
handler=@router.ApiHandler(api_health),
),
// API route (POST)
@router.SolRoutes::Post(
path="/api/submit",
handler=@router.ApiHandler(api_submit),
),
// Nested layout
@router.SolRoutes::Layout(
segment="/admin",
layout=admin_layout,
children=[
@router.SolRoutes::Page(path="/", handler=@router.PageHandler(admin_dashboard), title="Admin", meta=[], revalidate=None, cache=None),
@router.SolRoutes::Page(path="/users", handler=@router.PageHandler(admin_users), title="Users", meta=[], revalidate=None, cache=None),
],
),
// Apply middleware
@router.SolRoutes::WithMiddleware(
middleware=[@middleware.cors(), @middleware.logger()],
children=[
@router.SolRoutes::Get(path="/api/data", handler=@router.ApiHandler(api_data)),
],
),
]
}
pub fn config() -> @router.RouterConfig {
@router.RouterConfig::default()
.with_default_head(head())
.with_loader_url("/static/loader.js")| Variant | Description |
|---|---|
| Page | Page route (HTML response) |
| Get | GET API route (JSON response) |
| Post | POST API route (JSON response) |
| Layout | Nested layout group |
| WithMiddleware | Route group with middleware applied |
// Admin section layout
using @server_dom {
h1,
nav,
div,
text,
sol_link,
}
fn admin_layout(
props : @router.PageProps,
content : @server_dom.ServerNode,
) -> @server_dom.ServerNode raise {
@server_dom.ServerNode::sync(@luna.fragment([
h1([text("Admin Panel")]),
nav([
sol_link(href="/admin", [text("Dashboard")]),
sol_link(href="/admin/users", [text("Users")]),
]),
div(class="admin-content", [content.to_vnode()]),
]))
}
// Route definition
// segment="/admin" + path="/" => /admin
// segment="/admin" + path="/users" => /admin/users
@router.SolRoutes::Layout(
segment="/admin", // URL prefix
layout=admin_layout, // Layout function
children=[
// /admin
@router.SolRoutes::Page(path="/", handler=@router.PageHandler(admin_dashboard), title="Admin", meta=[], revalidate=None, cache=None),
// /admin/users
@router.SolRoutes::Page(path="/users", handler=@router.PageHandler(admin_users), title="Users", meta=[], revalidate=None, cache=None),
],
)// Middleware composition
let middleware = @middleware.logger()
.then(@middleware.cors())
.then(@middleware.security_headers())
// Apply to routes
@router.SolRoutes::WithMiddleware(
middleware=[middleware],
children=[...],
)| Middleware | Description |
|---|---|
| logger() | Request logging |
| cors() | CORS headers |
| csrf() | CSRF protection |
| security_headers() | Security headers |
| nosniff() | X-Content-Type-Options |
| frame_options(value) | X-Frame-Options |
@middleware.cors_with_config(
@middleware.CorsConfig::default()
.with_origin_single("https://example.com")
.with_methods(["GET", "POST"])
.with_credentials()
)@middleware.security_headers_with_config(
@middleware.SecurityHeadersConfig::default()
.with_csp("default-src 'self'")
.with_frame_options("DENY")
)// Sequential execution (m1 → m2)
let combined = @middleware.then_(m1, m2)
// or
let combined = m1.then(m2)
// Compose from array
let pipeline = @middleware.pipeline([m1, m2, m3])struct SubmitFormRequest {
value : String
} derive(FromJson)
struct SubmitFormResponse {
success : Bool
} derive(ToJson)
// Define action handler
let submit_form_handler = @action.ActionHandler(async fn(ctx) {
let req : SubmitFormRequest = match ctx.decode_json() {
Some(req) => req
None => return @action.ActionResult::bad_request("Invalid JSON payload")
}
// ... processing with req
@action.ActionResult::json(SubmitFormResponse::{ success: true })
})
// Register to registry
pub fn action_registry() -> @action.ActionRegistry {
@action.ActionRegistry::new(allowed_origins=["http://localhost:7777"])
.register(
@action.ActionDef::from_key(
@types.action_submit_form(),
submit_form_handler,
),
)
}| Type | Description |
|---|---|
| Success(data) | Success, returns JSON data |
| Redirect(url) | Client-side redirect (returns JSON with redirect instruction) |
| HttpRedirect(url) | HTTP redirect (returns 302 with Location header) |
| ClientError(status, msg) | Client error (4xx) |
| ServerError(msg) | Server error (5xx) |
// app/client/counter.mbt
pub fn counter(count : @signal.Signal[Int]) -> @luna.Node[CounterAction] {
div(class="counter", [
span(class="count-display", [text_of(count)]),
button(onclick=@luna.action(Increment), [text("+")]),
button(onclick=@luna.action(Decrement), [text("-")]),
])
}// Auto-generated: app/__gen__/types/types.mbt
pub struct CounterProps { initial_count : Int } derive(ToJson, FromJson)
pub fn counter(props : CounterProps, trigger~ : @luna.Trigger) -> @luna.ComponentRef[CounterProps]// app/server/home.mbt
let counter_props : @types.CounterProps = { initial_count: 42 }
@sol.island(
@types.counter(counter_props),
[div([button([text("Count: 42")])])], // SSR fallback children
)@server_dom.client(
@types.counter(counter_props),
[div([text("Loading...")])],
)@sol.island_raw("counter", "/static/counter.js", props_json, children)| Trigger | Description |
|---|---|
| Load | Immediately on page load |
| Idle | On requestIdleCallback |
| Visible | On IntersectionObserver detection |
| Media(query) | On media query match |
| None | Manual trigger |
sol_link(href="/about", [text("About")])@server_dom.ServerNode::async_(async fn() {
let data = fetch_data() // async function call
div([text(data)])
})pub fn config() -> @router.RouterConfig {
@router.RouterConfig::default()
.with_default_head(head())
.with_loader_url("/static/loader.js")
.with_streaming_ssr()
}Note: streaming response uses root_template when __LUNA_MAIN__ exists; otherwise it falls back to built-in page shell.
src/sol/
├── runtime.mbt # Core API
├── compiler.mbt # Rolldown bindings
├── router/ # Hono router integration
│ ├── router.mbt # Route registration
│ ├── sol_routes.mbt # SolRoutes definition
│ └── fragment.mbt # Fragment response (CSR)
├── middleware/ # Middleware system
│ ├── types.mbt # MwContext, MwRequest, MwResponse
│ ├── compose.mbt # Composition functions (then, pipeline)
│ ├── logger.mbt # Logger middleware
│ ├── cors.mbt # CORS middleware
│ ├── csrf.mbt # CSRF middleware
│ └── security_headers.mbt # Security headers
├── action/ # Server Actions
│ ├── action.mbt # ActionHandler, ActionResult
│ └── router.mbt # register_actions
└── cli/ # CLI tools
├── main.mbt # Entry point
├── new.mbt # sol new
├── dev.mbt # sol dev
├── build.mbt # sol build
├── serve.mbt # sol serve
├── generate.mbt # sol generate
└── clean.mbt # sol cleanpub struct BundleConfig {
input : Array[(String, String)]
output_dir : String
format : OutputFormat
entry_file_names : String
chunk_file_names : String
minify : Bool
sourcemap : Bool
}pub struct IslandConfig[E] {
id : String
script_url : String
trigger : TriggerType
state : StateConfig
content : Node[E, String]?
ssr_html : String?
}pub struct IslandManifestEntry {
name : String
entry_path : String
}pub enum OutputFormat {
Esm
}#external
pub type RolldownChunk#external
pub type RolldownOutputpub(all) struct StaticDirServeConfig {
path_prefix : String
local_dir : String
}pub struct StaticFileConfig {
path_prefix : String
local_dir : String
mappings : Array[(String, String)]
cache_control : String
}fn StaticFileConfig::with_mapping(self : StaticFileConfig, url_filename : String, local_path : String) -> StaticFileConfigapi_get("/api/users/:id", get_user)api_post("/api/users", create_user)fn build_from_manifest_file(manifest_path : String, cwd : String, on_success : (Array[String]) -> Unit, on_error : (String) -> Unit) -> Unitasync fn bundle_islands_prod(islands : Array[(String, String)], output_dir : String) -> RolldownOutputasync fn bundle_islands_result(islands : Array[(String, String)], output_dir : String) -> BundleResultfn create_app(routes : Array[SolRoutes], config? : RouterConfig, actions? : ActionRegistry) -> Serverfn generate_state_script(id : String, json : String) -> Stringfn[T : ToJson, E] island(cref : ComponentRef[T], children : Array[Node[E, String]]) -> Node[E, String]@sol.island(
@types.counter({ initial_count: 42 }),
[div([button([text("Count: 42")])])],
)fn[E] island_page(app : Server, path : String, render_fn : (Context) -> Array[IslandConfig[E]], title? : String, head? : String, loader_url? : String) -> Unitfn[E] island_raw(name : String, url : String, state : String, children : Array[Node[E, String]], trigger? : TriggerType) -> Node[E, String]fn[T : ToJson, E] island_with(cref : ComponentRef[T], render : () -> Node[E, String]) -> Node[E, String]fn[E] island_with_raw(name : String, url : String, state : String, render : () -> Node[E, String], trigger? : TriggerType) -> Node[E, String]nodes([
@element.h1([@element.text("Hello")]),
@element.p([@element.text("World")]),
])ok([("status", "ok".to_json())]).to_json()fn render_island_page(islands : Array[String], title? : String, head? : String, loader_url? : String) -> Stringfn[E] render_page(node : Node[E, String], title? : String, head? : String, hydration? : Bool) -> Stringfn[E] render_page_with_preloads(node : Node[E, String], title? : String, head? : String, hydration? : Bool) -> Stringfn route(path : String, handler : async (PageProps) -> ServerNode, title? : String, meta? : Array[(String, String)], revalidate? : Int?, cache? : CacheStrategy?, styles? : Array[String], scripts? : Array[String]) -> SolRoutesfn route_ownership_manifest(routes : Array[SolRoutes], config? : RouterConfig) -> RouteOwnershipManifestfn main {
@sol.run(fn(app) { @sol.page(app, "/", home_page) |> @sol.serve_static() })
}fn main {
@sol.run_app(configure_app)
}@sol.server_island(
id="user-profile",
props=Json::Object({ "userId": Json::String("123") }),
fallback=div([text("Loading...")]),
)fn[E] wc_island_raw(name : String, url : String, styles : String, state : String, children : Array[Node[E, String]], trigger? : TriggerType) -> Node[E, String]fn[E] wc_island_with_raw(name : String, url : String, styles : String, state : String, render : () -> Node[E, String], trigger? : TriggerType) -> Node[E, String]fn with_assets(content : Node[Unit, String], styles? : Array[String], scripts? : Array[String]) -> ServerNodewith_mw([@mw.logger(), @mw.cors()], [
api_get("/api/data", get_data),
])fn wrap(segment : String, layout : (PageProps, ServerNode) -> ServerNode raise, children : Array[SolRoutes]) -> SolRouteswrap("/admin", admin_layout, [
page("/", admin_dashboard),
page("/settings", admin_settings),
])Install
Download zipThin Mars wrapper for file-based routing, SSR, and asset loading