| Crates.io | soroban-render-sdk |
| lib.rs | soroban-render-sdk |
| version | 0.1.5 |
| created_at | 2025-12-25 15:14:03.986444+00 |
| updated_at | 2026-01-22 14:06:03.481463+00 |
| description | SDK for building Soroban Render contracts with reduced boilerplate |
| homepage | |
| repository | https://github.com/wyhaines/soroban-render-sdk |
| max_upload_size | |
| id | 2004645 |
| size | 386,712 |
A Rust SDK for building Soroban Render contracts with reduced boilerplate.
Add to your Cargo.toml:
[dependencies]
soroban-render-sdk = "0.1.0"
#![no_std]
use soroban_sdk::{contract, contractimpl, Address, Env, String};
use soroban_render_sdk::prelude::*;
soroban_render!(markdown);
#[contract]
pub struct HelloContract;
#[contractimpl]
impl HelloContract {
pub fn render(env: Env, _path: Option<String>, viewer: Option<Address>) -> Bytes {
MarkdownBuilder::new(&env)
.h1("Hello, World!")
.paragraph("Welcome to Soroban Render.")
.build()
}
}
The SDK provides several modules that can be selectively included:
MarkdownBuilder for fluent markdown constructionJsonDocument for JSON UI formatStyleBuilder for CSS stylesheet generationBaseRegistry for multi-contract applicationsDisable defaults to reduce size:
[dependencies]
soroban-render-sdk = { version = "0.1.0", default-features = false, features = ["markdown"] }
Enable registry for multi-contract apps:
[dependencies]
soroban-render-sdk = { version = "0.1.0", features = ["registry"] }
Declare render support:
// Shorthand for both contractmeta declarations
soroban_render!(markdown);
soroban_render!(json);
soroban_render!(markdown, json);
Build markdown content with a fluent API:
let output = MarkdownBuilder::new(&env)
.h1("Title")
.paragraph("Content here.")
.tip("This is a tip callout.")
.render_link("Home", "/")
.tx_link_id("Delete", "delete_item", 42)
.form_link("Submit", "add_item")
// Target specific contracts via registry alias
.form_link_to("Update", "admin", "set_value")
.tx_link_to("Flag", "content", "flag_post", r#"{"id":1}"#)
.include("CONTRACT_ID", "header")
.build();
Build JSON UI documents:
let output = JsonDocument::new(&env, "My App")
.heading(1, "Welcome")
.text("Hello, World!")
.form("add_item")
.text_field("name", "Enter name", true)
.submit("Add")
.divider()
.build();
Declarative path-based routing:
pub fn render(env: Env, path: Option<String>, viewer: Option<Address>) -> Bytes {
Router::new(&env, path)
.handle(b"/", |_| render_home(&env))
.or_handle(b"/tasks", |_| render_tasks(&env))
.or_handle(b"/task/{id}", |req| {
let id = req.get_var_u32(b"id").unwrap_or(0);
render_task(&env, id)
})
.or_handle(b"/files/*", |req| {
let path = req.get_wildcard().unwrap();
render_file(&env, path)
})
.or_default(|_| render_home(&env))
}
Build CSS stylesheets with a fluent API:
use soroban_render_sdk::styles::StyleBuilder;
let css = StyleBuilder::new(&env)
// CSS variables
.root_vars_start()
.var("primary", "#0066cc")
.var("bg", "#ffffff")
.var("text", "#333333")
.root_vars_end()
// Standard rules
.rule("body", "background: var(--bg); color: var(--text);")
// Multi-line rules
.rule_start("h1")
.prop("color", "var(--primary)")
.prop("font-size", "2rem")
.prop("margin-bottom", "1rem")
.rule_end()
// Dark mode override
.dark_mode_start()
.rule_start(":root")
.prop("--bg", "#1a1a1a")
.prop("--text", "#e0e0e0")
.rule_end()
.media_end()
// Responsive breakpoints
.breakpoint_max(768)
.rule("h1", "font-size: 1.5rem;")
.media_end()
.build();
| Method | Description |
|---|---|
root_var(name, value) |
Single CSS variable: :root { --name: value; } |
root_vars_start() / root_vars_end() |
Start/end a :root block |
var(name, value) |
Add variable inside :root block |
rule(selector, properties) |
Inline rule: selector { properties } |
rule_start(selector) / rule_end() |
Start/end a multi-line rule |
prop(property, value) |
Add property inside rule block |
media_start(condition) / media_end() |
Generic media query |
dark_mode_start() |
@media (prefers-color-scheme: dark) |
light_mode_start() |
@media (prefers-color-scheme: light) |
breakpoint_min(px) |
Mobile-first: @media (min-width: Npx) |
breakpoint_max(px) |
Desktop-first: @media (max-width: Npx) |
comment(text) |
CSS comment: /* text */ |
raw(css) |
Insert raw CSS string |
For applications with multiple contracts, the SDK provides registry support that enables the viewer's form:@alias:method and tx:@alias:method protocols.
use soroban_render_sdk::registry::BaseRegistry;
use soroban_sdk::{symbol_short, Address, Env, Map};
// Initialize registry with admin and contract aliases
let mut contracts = Map::new(&env);
contracts.set(symbol_short!("theme"), theme_address);
contracts.set(symbol_short!("content"), content_address);
contracts.set(symbol_short!("perms"), permissions_address);
BaseRegistry::init(&env, &admin, contracts);
// Look up contracts by alias
let content = BaseRegistry::get_by_alias(&env, symbol_short!("content"));
// Get all registered contracts
let all = BaseRegistry::get_all(&env);
// Admin can register new contracts later
BaseRegistry::register(&env, symbol_short!("cache"), cache_address);
// Admin can remove contracts
BaseRegistry::unregister(&env, symbol_short!("cache"));
| Method | Description |
|---|---|
init(env, admin, contracts) |
Initialize with admin and initial contract map. Panics if already initialized. |
register(env, alias, address) |
Register or update a contract alias. Requires admin auth. |
get_by_alias(env, alias) |
Look up contract by alias. Returns Option<Address>. |
get_all(env) |
Get all registered contracts as Map<Symbol, Address>. |
get_admin(env) |
Get the registry admin address. |
unregister(env, alias) |
Remove a contract alias. Requires admin auth. |
To use the registry with the viewer, your registry contract must expose a get_contract_by_alias function:
#[contract]
pub struct MyRegistry;
#[contractimpl]
impl MyRegistry {
pub fn initialize(env: Env, admin: Address, theme: Address, content: Address) {
let mut contracts = Map::new(&env);
contracts.set(symbol_short!("theme"), theme);
contracts.set(symbol_short!("content"), content);
BaseRegistry::init(&env, &admin, contracts);
}
// Required: The viewer calls this to resolve @alias references
pub fn get_contract_by_alias(env: Env, alias: Symbol) -> Option<Address> {
// Handle self-reference
if alias == symbol_short!("registry") {
return Some(env.current_contract_address());
}
BaseRegistry::get_by_alias(&env, alias)
}
}
The SDK provides emit_aliases() to generate {{aliases ...}} tags that enable include resolution with friendly names:
use soroban_render_sdk::registry::BaseRegistry;
// In your render function:
let aliases = BaseRegistry::emit_aliases(&env);
MarkdownBuilder::new(&env)
.raw(aliases) // {{aliases theme=CXYZ... content=CABC... }}
.h1("My Page")
// ...
For cross-contract calls, expose a public function:
pub fn render_aliases(env: Env) -> Bytes {
BaseRegistry::emit_aliases(&env)
}
Other contracts can then fetch and emit aliases in their render output:
fn fetch_aliases(env: &Env) -> Bytes {
let registry: Address = /* get registry address */;
let args: Vec<Val> = Vec::new(env);
env.try_invoke_contract::<Bytes, soroban_sdk::Error>(
®istry,
&Symbol::new(env, "render_aliases"),
args,
).ok().and_then(|r| r.ok()).unwrap_or(Bytes::new(env))
}
Once you have a registry, use form_link_to and tx_link_to to target specific contracts:
// Form targeting the content contract
builder.form_link_to("Post Reply", "content", "create_reply")
// Generates: [Post Reply](form:@content:create_reply)
// Transaction targeting the permissions contract
builder.tx_link_to("Flag", "perms", "flag_content", r#"{"id":1}"#)
// Generates: [Flag](tx:@perms:flag_content {"id":1})
The viewer resolves @content and @perms by calling your registry's get_contract_by_alias function.
For complex layouts, use div and span containers with CSS classes:
// Nested divs with classes
builder
.div_start("thread")
.h2("Thread Title")
.div_start("replies")
.paragraph("Reply content here...")
.div_end() // close replies
.div_end() // close thread
// Styled div with inline CSS
builder.div_start_styled("indented", "margin-left: 24px;")
.paragraph("Indented content")
.div_end()
// Inline spans
builder
.text("Status: ")
.span_start("status-badge success")
.text("Active")
.span_end()
For large content that exceeds execution limits, use progressive loading patterns:
Use continuation when content is split into indexed chunks:
// Render first 10 items, signal there are more
builder
.h2("Comments")
// ... render comments 0-9 ...
.continuation("comments", 10, Some(50)) // 40 more to load
// Generates: {{continue collection="comments" from=10 total=50}}
Use render_continue to trigger additional render() calls with a path:
// Load first batch of replies, then continue loading
builder
.h2("Replies")
// ... render first 10 replies ...
.render_continue("/b/1/t/0/replies/10") // fetch more from offset 10
// Generates: {{render path="/b/1/t/0/replies/10"}}
The viewer automatically fetches the path and inserts the result inline.
Reference specific chunks for lazy loading:
// Reference chunk by index
builder.chunk_ref("content", 3)
// Generates: {{chunk collection="content" index=3}}
// With loading placeholder
builder.chunk_ref_placeholder("body", 0, "Loading...")
// Generates: {{chunk collection="body" index=0 placeholder="Loading..."}}
For paginated content:
builder.continue_page("posts", 2, 10, 47) // page 2, 10 per page, 47 total
// Generates: {{continue collection="posts" page=2 per_page=10 total=47}}
The bytes module provides utilities for working with Bytes in Soroban contracts. This includes string conversion, JSON escaping, and comprehensive number-to-string conversion functions for all Rust integer types.
use soroban_render_sdk::bytes::*;
// Concatenate multiple Bytes
let result = concat_bytes(&env, &parts);
// Convert String to Bytes
let bytes = string_to_bytes(&env, &my_string);
// Escape for JSON output
let escaped = escape_json_string(&env, &my_string);
The SDK provides bidirectional conversion between numeric types and their Bytes string representations. These functions support all standard Rust integer types plus Soroban's U256 and I256.
Convert any numeric type to its decimal string representation as Bytes. Signed types automatically handle negative values.
let bytes = u64_to_bytes(&env, 12345);
// bytes contains "12345"
let bytes = i64_to_bytes(&env, -42);
// bytes contains "-42"
The full set of decimal conversion functions covers u32, i32, u64, i64, u128, i128, U256, and I256.
Convert numeric types to lowercase hexadecimal with a 0x prefix. Negative values use a -0x prefix.
let bytes = u64_to_hex(&env, 255);
// bytes contains "0xff"
let bytes = i32_to_hex(&env, -16);
// bytes contains "-0x10"
Parse a Bytes string back to a numeric type. These functions return Option<T> to handle invalid input safely.
let bytes = Bytes::from_slice(&env, b"12345");
let value = bytes_to_u64(&bytes);
// value is Some(12345)
let invalid = Bytes::from_slice(&env, b"abc");
let value = bytes_to_u64(&invalid);
// value is None
Parsing uses checked arithmetic to detect overflow. Values that exceed the target type's range return None.
Parse hexadecimal strings to numeric types. The 0x prefix is optional and parsing is case-insensitive.
let bytes = Bytes::from_slice(&env, b"0xFF");
let value = hex_to_u32(&bytes);
// value is Some(255)
let bytes = Bytes::from_slice(&env, b"ff");
let value = hex_to_u32(&bytes);
// value is Some(255)
When parsing form input, use the string_to_* functions that work directly with soroban_sdk::String.
let input = String::from_str(&env, "42");
let value = string_to_u32(&env, &input);
// value is Some(42)
For string literals or &str values, use the str_to_* functions for a more ergonomic API.
let value = str_to_u256(&env, "12345");
// value is Some(U256)
let n = str_to_i64(&env, "-42");
// n is Some(-42)
These avoid the need to create an intermediate soroban_sdk::String or Bytes.
All conversion functions are available for: u32, i32, u64, i64, u128, i128, U256, I256.
For the complete function reference, see llms-full.md.
pub fn render(env: Env, _path: Option<String>, viewer: Option<Address>) -> Bytes {
let mut parts: Vec<Bytes> = Vec::new(&env);
match viewer {
Some(_) => {
parts.push_back(Bytes::from_slice(&env, b"# Hello, User!\n\n"));
parts.push_back(Bytes::from_slice(&env, b"Your wallet is connected."));
}
None => {
parts.push_back(Bytes::from_slice(&env, b"# Hello, World!\n\n"));
parts.push_back(Bytes::from_slice(&env, b"Connect your wallet."));
}
};
Self::concat_bytes(&env, &parts)
}
fn concat_bytes(env: &Env, parts: &Vec<Bytes>) -> Bytes {
let mut result = Bytes::new(env);
for part in parts.iter() { result.append(&part); }
result
}
pub fn render(env: Env, _path: Option<String>, viewer: Option<Address>) -> Bytes {
let md = MarkdownBuilder::new(&env);
match viewer {
Some(_) => md.h1("Hello, User!").paragraph("Your wallet is connected."),
None => md.h1("Hello, World!").paragraph("Connect your wallet."),
}.build()
}
Complete documentation is available in the main Soroban Render repository:
Apache 2.0