Files
micronomicon/docs/dynamic-templates.md
2026-04-05 09:53:01 +02:00

851 lines
31 KiB
Markdown

# µFrame Dynamic Templates — Making Rich UIs Live on NomadNet
## Addendum to the µFrame Design Document (v3)
---
## 1. The NomadNet Dynamic Page Model
Understanding how NomadNet serves dynamic pages is essential to
understanding how µFrame templates become live applications.
### How it works
NomadNet has a simple but powerful execution model, analogous to
CGI on the early web:
```
Client Node Server
────── ───────────
1. Browse to /page/dashboard.mu
───────────▶
2. Is dashboard.mu executable?
YES → run it
NO → send contents as-is
3. Execute: #!/usr/bin/env python3
Script prints Micron to stdout
4. Capture stdout → send to client
◀───────────
5. Render Micron in terminal
```
Key mechanics:
- A `.mu` file without the execute bit is **static** — NomadNet
sends its contents directly to the browsing client
- A `.mu` file with the execute bit set is **dynamic** — NomadNet
runs it as a subprocess and serves whatever it prints to stdout
- The shebang line (`#!/usr/bin/env python3`) determines the
interpreter — Python, Bash, Lua, Rust, anything that runs
- The script must **terminate** — it cannot wait for input or
run indefinitely
- Cache behavior is controlled via a header line: `#!c=0` means
never cache (always re-execute), `#!c=300` means cache for 5 min
### Form data flow
Micron form fields (`\`<field\`placeholder>`, `\`<^|group|val\`label>`,
etc.) collect user input. When the user clicks a link on a page
that contains form fields, the field data is submitted along with
the link request:
```
Page A (has form fields + a submit link)
┌───────────────────────────────────────────────┐
│ Name: `<24|name`Enter name...> │
│ Role: `<^|role|admin`Admin> `<^|role|user`User> │
│ │
│ `[Submit`:/page/handle.mu] │
└───────────────────────────────────────────────┘
User fills in "Alice", selects "Admin", clicks Submit
Page B (handle.mu) — receives field data via environment
variables, generates a response page with Micron output
```
The submitted field data is passed to the executable script
through environment variables in the format:
```
FIELD_name=Alice
FIELD_role=admin
```
Or via stdin as a URL-encoded or structured data payload
(implementation varies by NomadNet version). The executable
script reads these values and uses them to generate its output.
---
## 2. µFrame's Dynamic Compilation Model
Here is the key insight: **µFrame doesn't just emit static
Micron text — it can compile `.uf` templates into executable
Python scripts that generate Micron at request time.**
This gives us three output modes:
```
.uf source ──▶ Parser ──▶ IR ──┬──▶ Plain ASCII (static preview)
├──▶ Static .mu (static page)
└──▶ Dynamic .mu (executable script)
#!/usr/bin/env python3
# Auto-generated by µFrame
# from: dashboard.uf
import os, sys, json, subprocess
...
print(rendered_micron)
```
The dynamic output is a self-contained Python script with:
- The µFrame rendering engine embedded (or imported)
- Data-fetching hooks that run at request time
- Form field processing from environment variables
- Conditional rendering based on submitted data
- The full ASCII art + Micron generation pipeline
### The three modes compared
```
┌──────────────────────────────────────────────────────────────┐
│ µFrame Output Modes │
├────────────────┬───────────────┬─────────────────────────────┤
│ Plain ASCII │ Static .mu │ Dynamic .mu │
├────────────────┼───────────────┼─────────────────────────────┤
│ Box-drawing │ Box-drawing │ Box-drawing │
│ Block chars │ Block chars │ Block chars │
│ Braille │ Braille │ Braille │
│ │ + Color │ + Color │
│ │ + Bold/italic │ + Bold/italic │
│ │ + Links │ + Links │
│ │ + Form fields │ + LIVE form fields │
│ │ │ + Server-side data binding │
│ │ │ + Conditional rendering │
│ │ │ + Form submission handling │
│ │ │ + System data at render time │
│ │ │ + State persistence │
├────────────────┼───────────────┼─────────────────────────────┤
│ Local terminal │ NomadNet page │ NomadNet live application │
│ preview │ (cached) │ (re-executed per request) │
└────────────────┴───────────────┴─────────────────────────────┘
```
---
## 3. DSL Extensions for Dynamic Behavior
### 3.1 Data Sources — `source` blocks
A `source` block declares where live data comes from. At render
time, the generated script executes the source and binds the
result to a variable.
```
# Shell command — output captured as string
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'"
source uptime : shell "uptime -p"
source peers : shell "rnstatus -j | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get(\"peers\",[]))); '"
# File read — contents loaded as string or parsed as JSON
source motd : file "/etc/motd"
source config : json "/home/node/.nomadnetwork/config.json"
# Python expression — evaluated inline (available: datetime, timedelta, secrets, os, json)
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
source rand_hex : python "secrets.token_hex(4)"
source uptime : python "str(timedelta(seconds=12345))"
source hostname : python "os.uname().nodename"
# RNS/Reticulum API — direct integration
source peer_list : rns "peers"
source node_info : rns "identity"
```
Sources are resolved **at page render time** — every time a
client requests the page, the commands run fresh.
Usage in templates:
```
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90
gauge "MEM" $mem_pct 100 28 warn=80 crit=95
label "Uptime" "$uptime"
label "Peers" "$peers active"
text "Last updated: $timestamp"
```
### 3.2 Form Handling — `on_submit` blocks
An `on_submit` block defines what happens when a form is
submitted. It receives field values and controls what the
page renders in response.
```
page "Search" 64
form "search"
field "query" 30 "Enter search term..."
radio "scope" "Local" | "Network" | "All"
button "Search" "/page/search.mu"
on_submit "search"
# $query and $scope are now populated from submitted form data
source results : shell "search_index.py '$query' --scope '$scope'"
heading 2 "Results for: $query"
if $results
text "$results"
else
text "No results found."
```
The generated Python script handles this as:
```python
#!/usr/bin/env python3
#!c=0
import os, subprocess
# Read submitted form data
query = os.environ.get("FIELD_query", "")
scope = os.environ.get("FIELD_scope", "Local")
if query:
# Form was submitted — render results
results = subprocess.check_output(
["search_index.py", query, "--scope", scope]
).decode().strip()
# ... render results template with Micron ...
else:
# No submission — render the form
# ... render form template with Micron ...
```
### 3.3 Conditional Rendering — `if` / `else` / `elif`
```
source disk_pct : shell "df / | awk 'NR==2{print int($5)}'"
if $disk_pct > 90
box heavy "DISK CRITICAL"
color f00
gauge "Disk" $disk_pct 100 40 crit=90
text "@bold{@color{f00}{Immediate action required!}}"
elif $disk_pct > 75
box light "Disk Warning"
color ff0
gauge "Disk" $disk_pct 100 40 warn=75
else
gauge "Disk" $disk_pct 100 40
```
### 3.4 Iteration — `for` loops
```
source peer_json : shell "rnstatus --json-peers"
heading 2 "Active Peers"
for peer in $peer_json
row 2
col 30
text "$peer.name"
col 10
status "$peer.name" $peer.state
col 10
text "$peer.latency"
```
The `for` construct works with JSON arrays or newline-delimited
text from shell commands.
### 3.5 State Persistence — `state` blocks
Since each page request is a fresh script execution, state must
be stored externally. µFrame provides a simple key-value store
backed by a JSON file on the node:
```
state "counter" "/tmp/uframe_counter.json"
# Read
let visits = $counter.visits || 0
# Write (increments on each page load)
set counter.visits = $visits + 1
text "This page has been viewed $counter.visits times."
```
For form-driven state (e.g., a guestbook):
```
state "guestbook" "/var/nomadnet/guestbook.json"
form "sign"
field "name" 20 "Your name..."
field "message" 40 "Your message..."
button "Sign" "/page/guestbook.mu"
on_submit "sign"
append guestbook.entries { name: $name, message: $message, time: $timestamp }
text "@color{0f0}{Thanks, $name! Your message has been saved.}"
heading 2 "Guestbook ($guestbook.entries.length entries)"
for entry in $guestbook.entries
box light
text "@bold{$entry.name} — @italic{$entry.time}"
text "$entry.message"
spacer
```
### 3.6 Page Navigation with Data — `link` with parameters
Links can pass data to the target page via query-style encoding:
```
# Simple navigation
link "Home" "/page/index.mu"
# Navigation with parameters
link "View Peer $peer.name" "/page/peer_detail.mu?hash=$peer.hash"
# In the target page, access with:
source peer_hash : param "hash"
```
### 3.7 Cache Control
```
page "Dashboard" 64
cache 0 # never cache — always re-execute
# cache 60 # cache for 60 seconds
# cache none # alias for 0
source cpu : shell "..."
...
```
Translates to the Micron header `#!c=0` in the first line of output.
---
## 4. Compilation Pipeline — .uf to Executable .mu
### 4.1 What the compiler generates
A `.uf` file with dynamic features compiles into a Python script
that:
1. Sets the shebang and cache header
2. Imports required modules + the `uframe` package
3. Defines runtime helpers (`_shell`, `_read_file`, `_read_json`, etc.)
4. Executes source commands and evaluates conditionals/loops
5. Dynamically builds a `.uf` source string with resolved variables
6. Compiles that source with `uframe.compile()` at runtime
7. Prints the resulting Micron to stdout
```python
#!/usr/bin/env python3
# Auto-generated by uFrame
# Do not edit — regenerate with: uframe compile <source>.uf
import os, sys, json, subprocess, datetime, secrets, shlex
from datetime import datetime as _dt_cls, timedelta
# ─── Runtime Helpers ─────────────────────────────────────────
def _shell(cmd, timeout=5):
"""Execute shell command, return stdout."""
try:
return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()
except Exception:
return ""
def _read_file(path):
"""Read file contents."""
# ...
def _read_json(path):
"""Read and parse JSON file."""
# ...
def _get_field(name, default=""):
"""Read submitted form field from environment."""
return os.environ.get(f"FIELD_{name}", default)
def _get_param(name, default=""):
"""Read URL parameter."""
return os.environ.get(f"PARAM_{name}",
os.environ.get(f"var_{name}", default))
def _load_state(path):
"""Load state from JSON file."""
# ...
def _save_state(path, data):
"""Save state to JSON file."""
# ...
def _iter(val):
"""Make a value iterable for for-loops."""
# handles lists, dicts, newline-delimited strings
# ─── µFrame Compile ──────────────────────────────────────────
import uframe
# ─── Page Logic ──────────────────────────────────────────────
_cache_seconds = 0
_uf_source_parts = []
cpu_pct = eval('secrets.randbelow(60) + 20', {'datetime': _dt_cls, ...})
timestamp = eval("datetime.now().strftime('%H:%M:%S')", {'datetime': _dt_cls, ...})
_uf_source_parts.append(f'heading 1 "Resources"')
_uf_source_parts.append(f'gauge "CPU" {cpu_pct} 100 28 warn=75.0 crit=90.0')
_uf_source_parts.append(f'text "Updated: {timestamp}"')
if cpu_pct > 90:
_uf_source_parts.append(f'text "ALERT: CPU critical"')
# ─── Render & Output ─────────────────────────────────────────
_uf_source = f'''page "Live Status" 60
''' + "\n".join(_uf_source_parts)
result = uframe.compile(_uf_source, width=60)
if _cache_seconds >= 0:
print(f"#!c={_cache_seconds}")
print(result.micron)
```
The key insight: the generated script **rebuilds `.uf` source** with
live data substituted in, then compiles it with the full µFrame
pipeline. This means every layout feature (boxes, gauges, tables,
sparklines) works identically in both static and dynamic pages.
### 4.2 CLI usage
```bash
# Compile to dynamic executable .mu
uframe compile dashboard.uf --out dashboard.mu
chmod +x dashboard.mu
# Compile and deploy in one step
uframe deploy dashboard.uf
# → renders, sets +x, copies to ~/.nomadnetwork/storage/pages/
# Compile with embedded vs. imported runtime
uframe compile dashboard.uf --embed # single self-contained file
uframe compile dashboard.uf --import # requires uframe_runtime.py on node
```
### 4.3 Compilation modes
```
┌────────────────────────────────────────────────────────────────┐
│ µFrame Compilation Modes │
├────────────────┬───────────────┬───────────────────────────────┤
│ uframe render │ uframe render │ uframe compile │
│ --ascii │ --micron │ │
├────────────────┼───────────────┼───────────────────────────────┤
│ Static ASCII │ Static .mu │ Executable .mu (Python) │
│ to stdout │ file │ file with +x │
├────────────────┼───────────────┼───────────────────────────────┤
│ All values │ All values │ source{} values fetched │
│ resolved at │ resolved at │ at request time │
│ render time │ render time │ │
│ │ │ Form fields become live │
│ │ │ on_submit{} blocks active │
│ │ │ if/for evaluated per request │
│ │ │ State persists across visits │
└────────────────┴───────────────┴───────────────────────────────┘
```
---
## 5. Complete Dynamic Example
### 5.1 Source — `search_node.uf`
```
page "Node Search" 64
cache 0
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
source peer_count : shell "rnstatus 2>/dev/null | grep -c 'Peer'"
state "history" "/var/nomadnet/search_history.json"
box double "Node Search"
align center
text "Find peers and pages on the Reticulum mesh"
text "@italic{$peer_count peers reachable · updated $timestamp}"
spacer
form "search"
field "query" 30 "Search term..."
radio "type" "Nodes" | "Pages" | "Files"
checkbox "cache" "Include cached results"
button "Search" "/page/search_node.mu"
on_submit "search"
# Log the search
append history.queries { q: $query, type: $type, time: $timestamp }
source results : shell "mesh_search.py '$query' --type '$type'"
source result_count : python "len('''$results'''.strip().splitlines())"
divider light
heading 2 "Results for \"$query\" ($result_count found)"
if $result_count > 0
for line in $results
source parts : python "'''$line'''.split('|')"
row 1
col 28
link "$parts.0" "/page/detail.mu?hash=$parts.1"
col 8
text "$parts.2"
col 10
status "$parts.0" $parts.3
else
spacer
text "@center{@color{ff0}{No results found for \"$query\"}}"
spacer
divider light
heading 3 "Recent Searches"
for entry in $history.queries[-5:]
text " $entry.time $entry.q ($entry.type)"
divider heavy
text "@center{@italic{Relay Alpha-7 · $timestamp}}"
```
### 5.2 What the client sees
**Before submission** (form is empty):
```
╔══ Node Search ═══════════════════════════════════════════════╗
║ Find peers and pages on the Reticulum mesh ║
║ 7 peers reachable · updated 14:32:07 ║
╚══════════════════════════════════════════════════════════════╝
Search: [ Search term...___________________ ]
Type: (•) Nodes ( ) Pages ( ) Files
[ ] Include cached results
`[`!Search`!`:/page/search_node.mu]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Relay Alpha-7 · 14:32:07
```
**After submitting "relay"** (script re-executes with FIELD_query=relay):
```
╔══ Node Search ═══════════════════════════════════════════════╗
║ Find peers and pages on the Reticulum mesh ║
║ 7 peers reachable · updated 14:32:15 ║
╚══════════════════════════════════════════════════════════════╝
Search: [ relay__________________________ ]
Type: (•) Nodes ( ) Pages ( ) Files
[ ] Include cached results
`[`!Search`!`:/page/search_node.mu]
──────────────────────────────────────────────────────────────
>> Results for "relay" (3 found)
`F0cfRelay-East`f 2 hops `F0f0●`f online
`F0cfRelay-South`f 4 hops `F0f0●`f online
`F0cfRelay-Backup`f 6 hops `Fff0◐`f degraded
──────────────────────────────────────────────────────────────
>>> Recent Searches
14:32:15 relay (Nodes)
14:28:44 bridge (Pages)
14:25:01 firmware (Files)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Relay Alpha-7 · 14:32:15
```
The form fields retain submitted values, results appear below,
and the search history persists across visits via the state file.
Every piece of box-drawing, every colored indicator, every braille
sparkline renders identically in both ASCII preview and live Micron.
---
## 6. Dynamic Patterns — A Cookbook
### 6.1 Live Dashboard (auto-refresh via cache=0)
```
page "Status" 64
cache 0
source cpu : python "secrets.randbelow(60) + 20"
source mem : python "secrets.randbelow(40) + 50"
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
box heavy "System Status"
row 2
gauge "CPU" $cpu 100 28 warn=75 crit=90
gauge "MEM" $mem 100 28 warn=80 crit=95
spacer
label "Uptime" "$uptime"
label "Updated" "$timestamp"
text "@center{@italic{Press Ctrl+R to refresh}}"
```
Client hits the page → script runs → evaluates sources →
renders gauges with live data → client sees it.
Ctrl+R re-requests → fresh execution → updated values.
On a full Linux node, replace the python sources with shell commands
to read real system data:
```
source cpu : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
source uptime : shell "uptime -p"
```
### 6.2 Guestbook with Persistent State
```
page "Guestbook" 64
cache 0
state "gb" "/var/nomadnet/guestbook.json"
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
heading 1 "Guestbook"
form "sign"
field "name" 20 "Your name"
field "msg" 40 "Leave a message..."
button "Sign" "/page/guestbook.mu"
on_submit "sign"
if $name && $msg
prepend gb.entries { name: $name, msg: $msg, time: $timestamp }
text "@color{0f0}{✓ Thanks, $name!}"
divider light
for entry in $gb.entries[:20]
box rounded
text "@bold{$entry.name} @italic{@color{888}{$entry.time}}"
text "$entry.msg"
spacer
```
### 6.3 Multi-Page Wizard with Navigation
```
# Page 1: setup.mu
page "Setup Wizard — Step 1" 64
cache 0
heading 1 "Network Configuration"
form "net"
field "interface" 20 "eth0"
radio "mode" "Auto" | "Manual" | "Mesh Only"
button "Next →" "/page/setup_2.mu"
# Page 2: setup_2.mu
page "Setup Wizard — Step 2" 64
cache 0
source iface : param "interface" # or field from previous page
source mode : param "mode"
heading 1 "Confirm Settings"
label "Interface" "$iface"
label "Mode" "$mode"
form "confirm"
checkbox "apply_now" "Apply immediately"
button "← Back" "/page/setup.mu"
button "Finish ✓" "/page/setup_done.mu?interface=$iface&mode=$mode"
```
### 6.4 Chat Room (community pattern)
```
page "Chat" 64
cache 0
state "chat" "/var/nomadnet/chatlog.json"
source timestamp : python "datetime.now().strftime('%H:%M')"
heading 1 "Node Chat"
# Display last 15 messages
for msg in $chat.messages[-15:]
text "@bold{@color{$msg.color}{$msg.nick}} @color{888}{$msg.time}"
text " $msg.text"
divider light
form "send"
field "nick" 12 "Nickname"
field "text" 40 "Type message..."
button "Send" "/page/chat.mu"
on_submit "send"
if $nick && $text
source color : python "format(hash('$nick')%4095,'03x')"
append chat.messages { nick: $nick, text: $text, time: $timestamp, color: $color }
text "@center{@italic{@color{888}{Ctrl+R to refresh · $chat.messages.length messages}}}"
```
### 6.5 Interactive Data Explorer
```
page "Peer Explorer" 64
cache 0
source peers_json : shell "rnstatus --json 2>/dev/null"
source selected : param "hash"
heading 1 "Peer Explorer"
table "Peers"
columns "Name" 24 | "Hops" 6 | "RTT" 8 | "Status" 10
for peer in $peers_json.peers
row "$peer.name" | "$peer.hops" | "$peer.rtt" | "$peer.status"
if $selected
divider heavy
source detail : shell "rnstatus --peer $selected --json 2>/dev/null"
box double "Peer Detail: $detail.name"
label "Hash" "$detail.hash"
label "Address" "$detail.address"
label "Hops" "$detail.hops"
label "Latency" "$detail.rtt"
label "Last Seen" "$detail.last_seen"
label "Transport" "$detail.transport"
sparkline "Latency (24h)" $detail.latency_history 40
row 2
link "Ping" "/page/action.mu?cmd=ping&hash=$detail.hash"
link "Trace" "/page/action.mu?cmd=trace&hash=$detail.hash"
link "Browse" "$detail.hash:/page/index.mu"
```
---
## 7. Security Considerations
Dynamic pages execute code on the node server. µFrame enforces:
- **Shell command sanitization**: all `$variable` values interpolated
into shell commands are escaped with `shlex.quote()` to prevent
injection
- **State file isolation**: state files are restricted to a
configurable directory (default: `/var/nomadnet/uframe/`)
- **Execution timeout**: all shell sources have a default 5-second
timeout, configurable per source
- **No network egress by default**: source commands run in the
node's local context — they can read local system data but
µFrame does not add network capabilities beyond what the
scripts themselves invoke
- **Input validation**: field values are length-limited and
sanitized before use in sources or state operations
```
# In the DSL, explicit sanitization:
source result : shell "search.py" --arg $query --sanitize
timeout 10
max_length 256
```
---
## 8. Architecture Summary
```
┌──────────────────────────────┐
│ .uf Source │
│ (layout + sources + forms │
│ + conditionals + state) │
└──────────────┬───────────────┘
┌────▼────┐
│ Parse │
└────┬────┘
┌────▼────┐
│ IR │
│ Tree │
└──┬───┬──┘
│ │
┌──────────────┘ └────────────────┐
│ │
┌──────▼──────┐ ┌───────▼───────┐
│ render mode │ │ compile mode │
│ (immediate) │ │ (codegen) │
└──┬───────┬──┘ └───────┬───────┘
│ │ │
┌──────▼┐ ┌───▼─────┐ ┌───────▼────────┐
│ ASCII │ │ Static │ │ Executable .mu │
│ stdout│ │ .mu file│ │ Python script │
└───────┘ └─────────┘ │ with embedded │
│ runtime + │
│ data sources + │
│ form handling │
└───────┬────────┘
chmod +x
deploy to
┌─────────▼─────────┐
│ NomadNet Node │
│ ~/.nomadnetwork/ │
│ storage/pages/ │
│ │
│ Client request → │
│ Execute script → │
│ Stdout = Micron │
│ with live data, │
│ rich ASCII art, │
│ color + forms │
└───────────────────┘
```
The dynamic model turns µFrame from a static template engine into a
**full application framework for NomadNet** — where the same DSL that
defines the visual layout also defines the data flow, user interaction,
and server-side logic. The ASCII art isn't decoration — it's the UI
of a live, interactive, decentralized application running over
encrypted mesh networks.