use clap::Args;
use nativ_compiler::ir::node::*;
use nativ_compiler::ir::transform::lower_ast;
use nativ_compiler::ir::types::*;
use nativ_compiler::preview_model::{PreviewModel, build_preview_model};
use notify::{RecursiveMode, Watcher};
use serde::Serialize;
use std::fmt::Write as _;
use std::io::{Read, Write as IoWrite};
use std::net::{TcpListener, TcpStream, UdpSocket};
use std::path::{Path, PathBuf};
use std::sync::{
Arc, RwLock,
atomic::{AtomicU64, Ordering},
mpsc,
};
use std::thread;
use std::time::{Duration, Instant};
const DEBOUNCE: Duration = Duration::from_millis(300);
#[derive(Args)]
pub struct PreviewArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(short, long)]
pub out: Option<String>,
#[arg(long)]
pub open: bool,
#[arg(long)]
pub watch: bool,
#[arg(long)]
pub serve: bool,
#[arg(long)]
pub stdin: bool,
#[arg(long)]
pub json: bool,
#[arg(long, default_value_t = 4173)]
pub port: u16,
}
#[derive(Serialize)]
struct PreviewJsonResponse {
html: String,
source: Option<String>,
diagnostics: Vec<String>,
screens: usize,
model: PreviewModel,
}
pub fn run(args: PreviewArgs) -> Result<(), Box<dyn std::error::Error>> {
if args.stdin {
let mut source = String::new();
std::io::stdin()
.read_to_string(&mut source)
.map_err(|e| format!("failed to read stdin: {e}"))?;
let (html, screens, model) = render_source_preview("stdin", "stdin", &source)?;
if args.json {
write_json_response(html, Some(source), screens, model)?;
} else {
print!("{html}");
}
return Ok(());
}
let input = resolve_input(Path::new(&args.path))?;
if args.json {
let (html, screens, model) = render_to_preview(&input)?;
write_json_response(html, None, screens, model)?;
return Ok(());
}
let out_path = match &args.out {
Some(p) => PathBuf::from(p),
None => default_out_path(&input),
};
let abs = render_to_path(&input, &out_path)?;
println!("Preview written to {}", abs.display());
let server = if args.serve {
let html = std::fs::read_to_string(&abs)?;
let state = PreviewServerState::new(html);
let addr = start_preview_server(args.port, state.clone())?;
println!(
"Serving device preview at http://{}:{}/",
local_ip_hint(),
addr.port()
);
if args.open {
open_target(&format!("http://127.0.0.1:{}/", addr.port()))?;
}
Some(state)
} else {
None
};
if args.open && !args.serve {
open_in_browser(&abs)?;
}
if args.watch {
watch_loop(&input, &out_path, args.open, server)?;
} else if args.serve {
println!("Serving until Ctrl+C...");
loop {
thread::park();
}
}
Ok(())
}
fn write_json_response(
html: String,
source: Option<String>,
screens: usize,
model: PreviewModel,
) -> Result<(), Box<dyn std::error::Error>> {
let response = PreviewJsonResponse {
html,
source,
diagnostics: Vec::new(),
screens,
model,
};
let mut stdout = std::io::stdout().lock();
serde_json::to_writer(&mut stdout, &response)?;
stdout.write_all(b"\n")?;
Ok(())
}
fn render_to_path(input: &Path, out_path: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let (html, _) = render_to_string(input)?;
std::fs::write(out_path, html)
.map_err(|e| format!("failed to write {}: {e}", out_path.display()))?;
Ok(std::fs::canonicalize(out_path).unwrap_or_else(|_| out_path.to_path_buf()))
}
pub(crate) fn render_to_string(
input: &Path,
) -> Result<(String, usize), Box<dyn std::error::Error>> {
let (html, screens, _) = render_to_preview(input)?;
Ok((html, screens))
}
fn render_to_preview(
input: &Path,
) -> Result<(String, usize, PreviewModel), Box<dyn std::error::Error>> {
if input.is_file() {
render_single_file(input)
} else if input.is_dir() {
render_project_directory(input)
} else {
Err(format!("path not found: {}", input.display()).into())
}
}
fn render_single_file(
input: &Path,
) -> Result<(String, usize, PreviewModel), Box<dyn std::error::Error>> {
let source = std::fs::read_to_string(input)
.map_err(|e| format!("failed to read {}: {e}", input.display()))?;
let title = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "preview".to_string());
render_source_preview(&input.display().to_string(), &title, &source)
}
fn render_source_preview(
source_name: &str,
title: &str,
source: &str,
) -> Result<(String, usize, PreviewModel), Box<dyn std::error::Error>> {
let ast =
nativ_compiler::parse(source).map_err(|e| format!("failed to parse {source_name}: {e}"))?;
let program = lower_ast(&ast).map_err(|errs| {
let mut joined = String::new();
for err in &errs {
let _ = writeln!(joined, " {err}");
}
format!("failed to lower {source_name}:\n{joined}")
})?;
let html = render_preview(title, &program);
let screens = program.screens.len();
let model = build_preview_model(title, &program);
Ok((html, screens, model))
}
fn render_project_directory(
dir: &Path,
) -> Result<(String, usize, PreviewModel), Box<dyn std::error::Error>> {
let (program, title) = program_from_dir(dir)?;
let html = render_preview(&title, &program);
let screens = program.screens.len();
let model = build_preview_model(&title, &program);
Ok((html, screens, model))
}
fn program_from_dir(dir: &Path) -> Result<(IrProgram, String), Box<dyn std::error::Error>> {
let sources = discover_nativ_files(dir)?;
if sources.is_empty() {
return Err(format!("no .nativ files found in {}", dir.display()).into());
}
let mut files = Vec::new();
for source in &sources {
let content = std::fs::read_to_string(source)
.map_err(|e| format!("failed to read {}: {e}", source.display()))?;
let ast = nativ_compiler::parse(&content)
.map_err(|e| format!("failed to parse {}: {e}", source.display()))?;
files.push(ast);
}
let merged = nativ_compiler::ast::merge_nativ_files(&files);
let program = lower_ast(&merged).map_err(|errs| {
let mut joined = String::new();
for err in &errs {
let _ = writeln!(joined, " {err}");
}
format!("failed to lower project:\n{joined}")
})?;
let title = dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".to_string());
Ok((program, title))
}
pub(crate) fn render_web_project(
dir: &Path,
) -> Result<(String, usize), Box<dyn std::error::Error>> {
let (program, title) = program_from_dir(dir)?;
let html = render_web_app(&title, &program);
let screens = program.screens.len();
Ok((html, screens))
}
fn discover_nativ_files(dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
match nativ_pipeline::discover_sources(dir) {
Ok(sources) if !sources.is_empty() => return Ok(sources),
_ => {}
}
let mut files = Vec::new();
collect_nativ_files(dir, &mut files)?;
files.sort();
Ok(files)
}
fn collect_nativ_files(
dir: &Path,
out: &mut Vec<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_nativ_files(&path, out)?;
} else if path.extension().is_some_and(|ext| ext == "nativ") {
out.push(path);
}
}
Ok(())
}
fn watch_loop(
input: &Path,
out_path: &Path,
opened: bool,
server: Option<PreviewServerState>,
) -> Result<(), Box<dyn std::error::Error>> {
let watch_root: PathBuf = if input.is_file() {
input
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
} else {
let src = input.join("src");
if src.is_dir() {
src
} else {
input.to_path_buf()
}
};
let display_path = input.display().to_string();
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
for path in event.paths {
let _ = tx.send(path);
}
}
})?;
watcher.watch(&watch_root, RecursiveMode::Recursive)?;
if opened {
println!(
"Watching {} for changes... (the browser shows the file in place; refresh the tab to see updates)",
display_path
);
} else {
println!("Watching {} for changes... (Ctrl+C to stop)", display_path);
}
while let Ok(first) = rx.recv() {
let mut batch = vec![first];
let deadline = Instant::now() + DEBOUNCE;
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
match rx.recv_timeout(remaining) {
Ok(path) => batch.push(path),
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
}
}
if !batch.iter().any(|p| triggers_rerender(p)) {
continue;
}
print!("\r\x1b[2K");
match render_to_string(input) {
Ok((html, screens)) => match std::fs::write(out_path, &html) {
Ok(()) => {
if let Some(server) = &server {
server.update(html);
}
let abs =
std::fs::canonicalize(out_path).unwrap_or_else(|_| out_path.to_path_buf());
let plural = if screens == 1 { "screen" } else { "screens" };
println!(
"Re-rendered {} ({screens} {plural}) at change",
abs.display()
);
}
Err(e) => eprintln!(
"Preview re-render failed: failed to write {}: {e}",
out_path.display()
),
},
Err(e) => eprintln!("Preview re-render failed: {e}"),
}
}
Ok(())
}
fn triggers_rerender(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext == "nativ")
}
#[derive(Clone)]
struct PreviewServerState {
html: Arc<RwLock<String>>,
version: Arc<AtomicU64>,
}
impl PreviewServerState {
fn new(html: String) -> Self {
Self {
html: Arc::new(RwLock::new(html)),
version: Arc::new(AtomicU64::new(1)),
}
}
fn update(&self, html: String) {
if let Ok(mut current) = self.html.write() {
*current = html;
self.version.fetch_add(1, Ordering::Relaxed);
}
}
}
fn start_preview_server(
port: u16,
state: PreviewServerState,
) -> Result<std::net::SocketAddr, Box<dyn std::error::Error>> {
let listener = TcpListener::bind(("0.0.0.0", port))?;
let addr = listener.local_addr()?;
thread::spawn(move || {
for stream in listener.incoming().flatten() {
handle_preview_request(stream, &state);
}
});
Ok(addr)
}
fn handle_preview_request(mut stream: TcpStream, state: &PreviewServerState) {
let mut buf = [0; 1024];
let Ok(n) = stream.read(&mut buf) else {
return;
};
if n == 0 {
return;
}
let request = String::from_utf8_lossy(&buf[..n]);
let Some(path) = request.split_whitespace().nth(1) else {
return;
};
if path == "/__nativ_version" {
let body = state.version.load(Ordering::Relaxed).to_string();
write_http(&mut stream, "200 OK", "text/plain", &body);
return;
}
if path == "/" || path.ends_with(".html") {
let html = state
.html
.read()
.map(|html| inject_live_reload(&html))
.unwrap_or_else(|_| "preview unavailable".to_string());
write_http(&mut stream, "200 OK", "text/html; charset=utf-8", &html);
return;
}
write_http(&mut stream, "404 Not Found", "text/plain", "not found");
}
fn write_http(stream: &mut TcpStream, status: &str, content_type: &str, body: &str) {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
let _ = stream.shutdown(std::net::Shutdown::Write);
}
fn inject_live_reload(html: &str) -> String {
const SCRIPT: &str = r#"<script>
(function () {
var seen = null;
async function check() {
try {
var res = await fetch('/__nativ_version', { cache: 'no-store' });
var next = await res.text();
if (seen === null) seen = next;
else if (next !== seen) location.reload();
} catch (e) {}
}
setInterval(check, 500);
check();
})();
</script>"#;
html.replacen("</body>", &format!("{SCRIPT}\n</body>"), 1)
}
fn local_ip_hint() -> String {
UdpSocket::bind("0.0.0.0:0")
.and_then(|sock| {
let _ = sock.connect("8.8.8.8:80");
sock.local_addr()
})
.map(|addr| addr.ip().to_string())
.unwrap_or_else(|_| "<your-computer-ip>".to_string())
}
fn resolve_input(path: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
if path.is_file() || path.is_dir() {
return Ok(path.to_path_buf());
}
Err(format!("path not found: {}", path.display()).into())
}
fn default_out_path(input: &Path) -> PathBuf {
if input.is_file() {
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "preview".to_string());
input.with_file_name(format!("{stem}.preview.html"))
} else {
let dir_name = input
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".to_string());
input.join(format!("{dir_name}.preview.html"))
}
}
fn open_in_browser(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
open_target(&path.to_string_lossy())
}
fn open_target(target: &str) -> Result<(), Box<dyn std::error::Error>> {
let result = if cfg!(target_os = "windows") {
std::process::Command::new("cmd")
.args(["/C", "start", "", target])
.status()
} else if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(target).status()
} else {
std::process::Command::new("xdg-open").arg(target).status()
};
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("could not open browser: {e}").into()),
}
}
fn render_preview(title: &str, program: &IrProgram) -> String {
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
html.push_str("<meta charset=\"utf-8\">\n");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
let _ = writeln!(html, "<title>{} · Nativ preview</title>", esc(title));
html.push_str("<style>\n");
html.push_str(CSS);
html.push_str("</style>\n</head>\n<body>\n");
let app_name = program
.app_config
.as_ref()
.and_then(|a| a.display_name.clone().or_else(|| Some(a.name.clone())))
.unwrap_or_else(|| title.to_string());
html.push_str("<header class=\"page-head\">\n");
let _ = writeln!(html, " <h1>{}</h1>", esc(&app_name));
html.push_str(
" <p class=\"subtitle\">Static preview generated by <code>nativ preview</code></p>\n",
);
html.push_str("</header>\n");
html.push_str("<main class=\"frames\">\n");
if program.screens.is_empty() {
html.push_str("<p class=\"empty\">No screens found in this file.</p>\n");
}
for screen in &program.screens {
render_screen(&mut html, screen, program);
}
html.push_str("</main>\n</body>\n</html>\n");
html
}
fn render_web_app(title: &str, program: &IrProgram) -> String {
let start = program
.app_config
.as_ref()
.and_then(|a| a.start_screen.clone())
.or_else(|| program.screens.first().map(|s| s.name.clone()))
.unwrap_or_else(|| "Home".to_string());
let app_name = program
.app_config
.as_ref()
.and_then(|a| a.display_name.clone().or_else(|| Some(a.name.clone())))
.unwrap_or_else(|| title.to_string());
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
html.push_str("<meta charset=\"utf-8\">\n");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
let _ = writeln!(html, "<title>{} · Nativ web</title>", esc(&app_name));
html.push_str("<style>\n");
html.push_str(CSS);
html.push_str(WEB_CSS);
html.push_str("</style>\n</head>\n<body class=\"web-app\">\n");
html.push_str("<div class=\"phone\">\n <div class=\"notch\"></div>\n");
let _ = writeln!(
html,
" <div class=\"screen-bar\" id=\"screen-bar\">{}</div>",
esc(&start)
);
html.push_str(" <div class=\"screen-stage\">\n");
if program.screens.is_empty() {
html.push_str(" <p class=\"empty\">No screens found.</p>\n");
}
for screen in &program.screens {
let active = if screen.name == start { " active" } else { "" };
let _ = writeln!(
html,
" <section class=\"screen{active}\" data-screen=\"{}\">",
esc(&screen.name)
);
for node in &screen.body {
render_node(&mut html, node, program, 3, None);
}
html.push_str(" </section>\n");
}
html.push_str(" </div>\n</div>\n");
let mut state_init = String::new();
for screen in &program.screens {
for s in &screen.state {
let init = expr_to_js(&s.initial_value, None, program).unwrap_or_else(|| "null".into());
state_init.push_str(&format!("state.{} = {};", s.name, init));
}
}
html.push_str(&web_js(&state_init));
html.push_str("</body>\n</html>\n");
html
}
fn render_screen(html: &mut String, screen: &IrScreen, program: &IrProgram) {
html.push_str("<section class=\"phone\">\n");
html.push_str(" <div class=\"notch\"></div>\n");
let _ = writeln!(
html,
" <div class=\"screen-bar\">{}</div>",
esc(&screen.name)
);
html.push_str(" <div class=\"screen-body\">\n");
for node in &screen.body {
render_node(html, node, program, 2, None);
}
html.push_str(" </div>\n");
html.push_str("</section>\n");
}
fn render_node(
html: &mut String,
node: &IrNode,
program: &IrProgram,
depth: usize,
loop_var: Option<&str>,
) {
let pad = " ".repeat(depth);
match node {
IrNode::Text(t) => {
let style = text_style(t);
let style_attr = if style.is_empty() {
String::new()
} else {
format!(" style=\"{style}\"")
};
let _ = writeln!(
html,
"{pad}<p class=\"el text\"{style_attr}>{}</p>",
bound_content(&t.value, loop_var, program)
);
}
IrNode::Button(b) => {
let style = b
.color
.as_ref()
.map(|c| format!(" style=\"background:{};\"", ir_css_color(c)))
.unwrap_or_default();
let data_attr = button_data_attr(&b.actions);
let tap_attr = tap_attr(&b.actions, loop_var, program);
let _ = writeln!(
html,
"{pad}<button class=\"el btn\"{data_attr}{tap_attr}{style}>{}</button>",
bound_content(&b.label, loop_var, program)
);
}
IrNode::Image(img) => {
let label = expr_to_display(&img.source);
let round = matches!(img.shape, Some(Shape::Circle)) || img.border_width.is_some();
let mut style = String::new();
if let Some(size) = img.size {
let _ = write!(style, "width:{size}px;height:{size}px;");
}
if round {
style.push_str("border-radius:50%;");
} else if let Some(Shape::RoundedRect(r)) = img.shape {
let _ = write!(style, "border-radius:{r}px;");
}
let _ = writeln!(
html,
"{pad}<div class=\"el image\" style=\"{style}\">{}</div>",
esc(&label)
);
}
IrNode::Toggle(t) => {
let label_text =
bound_content(t.label.as_ref().unwrap_or(&t.binding), loop_var, program);
let bind_js = match &t.binding {
IrExpr::Variable { .. }
| IrExpr::PropertyAccess { .. }
| IrExpr::PropertyChain { .. } => expr_to_js(&t.binding, loop_var, program),
_ => None,
};
let tap = bind_js
.as_ref()
.map(|js| format!(" data-on-tap=\"{js} = !({js});\""))
.unwrap_or_default();
let bind = bind_js
.as_ref()
.map(|js| {
format!(" data-bind-toggle=\"{js}\" role=\"switch\" aria-checked=\"false\"")
})
.unwrap_or_default();
let _ = writeln!(
html,
"{pad}<label class=\"el toggle\"{tap}{bind}><span>{label_text}</span><span class=\"switch\"></span></label>",
);
}
IrNode::TextField(tf) => {
let bind = match &tf.binding {
IrExpr::Variable { .. }
| IrExpr::PropertyAccess { .. }
| IrExpr::PropertyChain { .. } => expr_to_js(&tf.binding, loop_var, program)
.map(|js| format!(" data-bind-input=\"{js}\"")),
_ => None,
};
let disabled = if bind.is_some() { "" } else { " disabled" };
let bind_attr = bind.unwrap_or_default();
let _ = writeln!(
html,
"{pad}<input class=\"el field\" type=\"text\" placeholder=\"{}\"{bind_attr}{disabled}>",
esc(&expr_to_display(&tf.placeholder))
);
}
IrNode::Spinner(_) => {
let _ = writeln!(html, "{pad}<div class=\"el spinner\"></div>");
}
IrNode::Divider(_) => {
let _ = writeln!(html, "{pad}<hr class=\"el divider\">");
}
IrNode::Spacer(_) => {
let _ = writeln!(html, "{pad}<div class=\"el spacer\"></div>");
}
IrNode::Chart(c) => {
let kind = match c.chart_type {
IrChartType::Bar => "bar",
IrChartType::Line => "line",
IrChartType::Pie => "pie",
IrChartType::Donut => "donut",
};
let h = c.height.unwrap_or(200);
let _ = writeln!(
html,
"{pad}<div class=\"el chart\" data-chart-kind=\"{kind}\" style=\"height:{h}px;\">{kind} chart</div>"
);
}
IrNode::Video(v) => {
let _ = writeln!(
html,
"{pad}<div class=\"el video\" style=\"height:200px;\">video: {}</div>",
bound_content(&v.source, loop_var, program)
);
}
IrNode::Canvas(c) => {
let shapes: Vec<&str> = c
.shapes
.iter()
.map(|s| match s.kind {
IrCanvasShapeKind::Circle => "circle",
IrCanvasShapeKind::Rect => "rect",
IrCanvasShapeKind::Line => "line",
IrCanvasShapeKind::Path => "path",
})
.collect();
let _ = writeln!(
html,
"{pad}<div class=\"el canvas\" style=\"height:200px;\">canvas: {}</div>",
shapes.join(", ")
);
}
IrNode::Column(c) => {
let gap = c.spacing.unwrap_or(8);
let pad_px = c.padding.unwrap_or(0);
let _ = writeln!(
html,
"{pad}<div class=\"el column\" style=\"gap:{gap}px;padding:{pad_px}px;\">"
);
for child in &c.children {
render_node(html, child, program, depth + 1, loop_var);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Row(r) => {
let gap = r.spacing.unwrap_or(8);
let _ = writeln!(html, "{pad}<div class=\"el row\" style=\"gap:{gap}px;\">");
for child in &r.children {
render_node(html, child, program, depth + 1, loop_var);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Scroll(s) => {
let _ = writeln!(html, "{pad}<div class=\"el scroll\">");
for child in &s.children {
render_node(html, child, program, depth + 1, loop_var);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Card(c) => {
let pad_px = c.padding.unwrap_or(16);
let radius = c.corner_radius.unwrap_or(12);
let _ = writeln!(
html,
"{pad}<div class=\"el card\" style=\"padding:{pad_px}px;border-radius:{radius}px;\">"
);
for child in &c.children {
render_node(html, child, program, depth + 1, loop_var);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Section(s) => {
let _ = writeln!(html, "{pad}<div class=\"el section\">");
let _ = writeln!(
html,
"{pad} <div class=\"section-title\">{}</div>",
esc(&s.title)
);
for child in &s.children {
render_node(html, child, program, depth + 1, loop_var);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Conditional(c) => {
match expr_to_js(&c.condition, loop_var, program) {
Some(cond_js) => {
let mut prior: Vec<String> = Vec::new();
let _ = writeln!(html, "{pad}<div data-bind-if=\"({cond_js})\">");
for child in &c.then_body {
render_node(html, child, program, depth + 1, loop_var);
}
let _ = writeln!(html, "{pad}</div>");
prior.push(format!("!({cond_js})"));
for (ei_cond, ei_body) in &c.else_if_branches {
if let Some(ei_js) = expr_to_js(ei_cond, loop_var, program) {
let mut full = prior.clone();
full.push(format!("({ei_js})"));
let cond = full.join(" && ");
let _ = writeln!(
html,
"{pad}<div data-bind-if=\"{cond}\" style=\"display:none\">"
);
for child in ei_body {
render_node(html, child, program, depth + 1, loop_var);
}
let _ = writeln!(html, "{pad}</div>");
prior.push(format!("!({ei_js})"));
}
}
if let Some(else_body) = &c.else_body {
let cond = prior.join(" && ");
let _ = writeln!(
html,
"{pad}<div data-bind-if=\"{cond}\" style=\"display:none\">"
);
for child in else_body {
render_node(html, child, program, depth + 1, loop_var);
}
let _ = writeln!(html, "{pad}</div>");
}
}
None => {
for child in &c.then_body {
render_node(html, child, program, depth, loop_var);
}
}
}
}
IrNode::Each(e) => {
let coll_js = expr_to_js(&e.collection, loop_var, program);
let container_attr = coll_js
.as_ref()
.map(|js| format!(" data-each=\"{js}\""))
.unwrap_or_default();
let _ = writeln!(html, "{pad}<div class=\"el each\"{container_attr}>");
for _ in 0..3 {
for child in &e.body {
render_node(html, child, program, depth + 1, loop_var);
}
}
let _ = writeln!(html, "{pad}</div>");
let _ = writeln!(html, "{pad}<template data-each-tpl=\"1\">");
for child in &e.body {
render_node(html, child, program, depth + 1, Some(&e.item_name));
}
let _ = writeln!(html, "{pad}</template>");
}
IrNode::ComponentCall(call) => {
match program.components.iter().find(|c| c.name == call.name) {
Some(component) => {
for child in &component.body {
render_node(html, child, program, depth, loop_var);
}
}
None => {
let _ = writeln!(
html,
"{pad}<div class=\"el placeholder\">{}</div>",
esc(&call.name)
);
}
}
}
IrNode::Form(f) => {
let _ = writeln!(html, "{pad}<div class=\"el form\">");
for input in &f.inputs {
let bind_attr = input
.binding
.as_ref()
.and_then(|binding| match binding {
IrExpr::Variable { .. }
| IrExpr::PropertyAccess { .. }
| IrExpr::PropertyChain { .. } => expr_to_js(binding, loop_var, program),
_ => None,
})
.map(|js| format!(" data-bind-input=\"{js}\""))
.unwrap_or_default();
let required = if input.rules.contains(&ValidationRule::Required) {
" data-required=\"1\""
} else {
""
};
let email = if input.rules.contains(&ValidationRule::Email) {
" data-email=\"1\""
} else {
""
};
let ty = if input.rules.contains(&ValidationRule::Secret) {
"password"
} else if input.rules.contains(&ValidationRule::Email) {
"email"
} else {
"text"
};
let _ = writeln!(
html,
"{pad} <input class=\"el field\" type=\"{ty}\" placeholder=\"{}\" data-form-field=\"{}\"{bind_attr}{required}{email}>",
esc(&input.label),
esc(&input.field_name)
);
let _ = writeln!(
html,
"{pad} <p class=\"form-error\" data-error-for=\"{}\"></p>",
esc(&input.field_name)
);
}
if let Some(submit) = &f.submit {
let data_attr = button_data_attr(&submit.actions);
let submit_tap = tap_js(&submit.actions, loop_var, program)
.map(|js| format!(" data-submit-tap=\"{}\"", esc(&js)))
.unwrap_or_default();
let endpoint = submit_endpoint_attr(&submit.actions);
let _ = writeln!(
html,
"{pad} <button class=\"el btn\" data-submit-form=\"1\"{submit_tap}{endpoint}{data_attr}>{}</button>",
esc(&expr_to_display(&submit.label))
);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::TapHandler { target, .. } => {
render_node(html, target, program, depth, loop_var);
}
IrNode::GestureHandler { target, .. } => {
render_node(html, target, program, depth, loop_var);
}
IrNode::Accessibility {
target,
accessibility,
..
} => {
let attrs = accessibility_attrs(accessibility);
let _ = writeln!(html, "{pad}<div{attrs}>");
render_node(html, target, program, depth + 1, loop_var);
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Load(_) | IrNode::Animate(_) | IrNode::Raw(_) => {
}
}
}
fn accessibility_attrs(accessibility: &IrAccessibility) -> String {
let mut attrs = String::new();
if let Some(label) = &accessibility.label {
let _ = write!(attrs, " aria-label=\"{}\"", esc(&expr_to_display(label)));
}
if let Some(hint) = &accessibility.hint {
let _ = write!(
attrs,
" aria-description=\"{}\"",
esc(&expr_to_display(hint))
);
}
if matches!(
accessibility.hidden,
Some(IrExpr::Literal(IrLiteral {
value: LiteralValue::Boolean(true),
..
}))
) {
attrs.push_str(" aria-hidden=\"true\"");
}
attrs
}
fn button_data_attr(actions: &[IrAction]) -> String {
for action in actions {
if let IrAction::Navigate(nav) = action {
return match nav {
IrNavAction::GoTo { screen, .. } => format!(" data-goto=\"{}\"", esc(screen)),
IrNavAction::GoBack { .. } => " data-back=\"1\"".to_string(),
_ => continue,
};
}
}
String::new()
}
fn tap_attr(actions: &[IrAction], loop_var: Option<&str>, program: &IrProgram) -> String {
tap_js(actions, loop_var, program)
.map(|js| format!(" data-on-tap=\"{}\"", esc(&js)))
.unwrap_or_default()
}
fn tap_js(actions: &[IrAction], loop_var: Option<&str>, program: &IrProgram) -> Option<String> {
let stmts: Vec<String> = actions
.iter()
.filter_map(|a| {
assign_to_js(a, loop_var, program).or_else(|| method_call_to_js(a, loop_var, program))
})
.collect();
(!stmts.is_empty()).then(|| stmts.join(" "))
}
fn submit_endpoint_attr(actions: &[IrAction]) -> String {
actions
.iter()
.find_map(|action| match action {
IrAction::Submit {
endpoint: Some(endpoint),
..
} => Some(format!(" data-submit-endpoint=\"{}\"", esc(endpoint))),
_ => None,
})
.unwrap_or_default()
}
fn assign_to_js(action: &IrAction, loop_var: Option<&str>, program: &IrProgram) -> Option<String> {
if let IrAction::Assign {
target, op, value, ..
} = action
{
let target_js = expr_to_js(target, loop_var, program)?;
let val_js = expr_to_js(value, loop_var, program)?;
let js_op = match op {
AssignOp::Set => "=",
AssignOp::Add => "+=",
AssignOp::Sub => "-=",
};
Some(format!("{target_js} {js_op} {val_js};"))
} else {
None
}
}
fn method_call_to_js(
action: &IrAction,
loop_var: Option<&str>,
program: &IrProgram,
) -> Option<String> {
if let IrAction::MethodCall {
target,
method,
args,
..
} = action
{
let tgt = expr_to_js(target, loop_var, program)?;
match method.as_str() {
"add" => {
let arg = args
.first()
.and_then(|a| expr_to_js(a, loop_var, program))?;
Some(format!("{tgt}.push({arg});"))
}
"remove" => {
let arg = args
.first()
.and_then(|a| expr_to_js(a, loop_var, program))?;
Some(format!("{tgt} = {tgt}.filter(x => x !== {arg});"))
}
"clear" => Some(format!("{tgt}.length = 0;")),
_ => None,
}
} else {
None
}
}
fn js_str(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n")
}
fn literal_to_js(value: &LiteralValue) -> String {
match value {
LiteralValue::Number(n) => {
if *n == (*n as i64) as f64 {
format!("{}", *n as i64)
} else {
format!("{n}")
}
}
LiteralValue::Text(s) => format!("'{}'", js_str(s)),
LiteralValue::Boolean(b) => b.to_string(),
LiteralValue::Null => "null".to_string(),
}
}
fn expr_to_js(expr: &IrExpr, loop_var: Option<&str>, program: &IrProgram) -> Option<String> {
let is_loop = |n: &str| loop_var.is_some_and(|lv| lv == n);
match expr {
IrExpr::Literal(lit) => Some(literal_to_js(&lit.value)),
IrExpr::Variable { name, .. } => Some(if is_loop(name) {
"__it".into()
} else {
format!("state.{name}")
}),
IrExpr::PropertyAccess {
object, property, ..
} => Some(if is_loop(object) {
format!("__it.{property}")
} else {
format!("state.{object}.{property}")
}),
IrExpr::PropertyChain { parts, .. } => {
if parts.is_empty() {
return None;
}
let root = if is_loop(&parts[0]) {
"__it".to_string()
} else {
format!("state.{}", parts[0])
};
let mut s = root;
for p in &parts[1..] {
s.push('.');
s.push_str(p);
}
Some(s)
}
IrExpr::StringInterpolation { parts, .. } => {
let mut pieces: Vec<String> = Vec::new();
for part in parts {
match part {
StringPart::Literal(l) => pieces.push(format!("'{}'", js_str(l))),
StringPart::Expr(e) => {
pieces.push(format!("String({})", expr_to_js(e, loop_var, program)?))
}
}
}
Some(format!("[{}].join('')", pieces.join(", ")))
}
IrExpr::BinaryOp {
op, left, right, ..
} => {
let js_op = match op {
BinaryOp::Add => "+",
BinaryOp::Sub => "-",
BinaryOp::Mul => "*",
BinaryOp::Div => "/",
BinaryOp::Eq => "===",
BinaryOp::NotEq => "!==",
BinaryOp::Lt => "<",
BinaryOp::Gt => ">",
BinaryOp::LtEq => "<=",
BinaryOp::GtEq => ">=",
BinaryOp::And => "&&",
BinaryOp::Or => "||",
};
Some(format!(
"({} {} {})",
expr_to_js(left, loop_var, program)?,
js_op,
expr_to_js(right, loop_var, program)?
))
}
IrExpr::UnaryOp { op, operand, .. } => {
let js_op = match op {
UnaryOp::Negate => "-",
UnaryOp::Not => "!",
};
Some(format!(
"{js_op}({})",
expr_to_js(operand, loop_var, program)?
))
}
IrExpr::IndexAccess { target, index, .. } => Some(format!(
"{}[{}]",
expr_to_js(target, loop_var, program)?,
expr_to_js(index, loop_var, program)?
)),
IrExpr::ListLiteral { items, .. } => {
let js_items: Vec<String> = items
.iter()
.map(|i| expr_to_js(i, loop_var, program).unwrap_or_else(|| "null".into()))
.collect();
Some(format!("[{}]", js_items.join(", ")))
}
IrExpr::ConstructorCall {
type_name, args, ..
} => constructor_to_js(type_name, args, loop_var, program),
_ => None,
}
}
fn constructor_to_js(
type_name: &str,
args: &[IrExpr],
loop_var: Option<&str>,
program: &IrProgram,
) -> Option<String> {
let model = program.models.iter().find(|m| m.name == type_name)?;
if args.len() > model.fields.len() {
return None;
}
let mut values: Vec<Option<String>> = vec![None; model.fields.len()];
let mut order: Vec<(usize, &IrModelField)> = model
.fields
.iter()
.enumerate()
.filter(|(_, f)| f.default_value.is_none())
.collect();
order.extend(
model
.fields
.iter()
.enumerate()
.filter(|(_, f)| f.default_value.is_some()),
);
for (arg, (field_idx, _)) in args.iter().zip(order) {
values[field_idx] = Some(expr_to_js(arg, loop_var, program)?);
}
for (idx, field) in model.fields.iter().enumerate() {
if values[idx].is_none() {
values[idx] = Some(
field
.default_value
.as_ref()
.and_then(|value| expr_to_js(value, loop_var, program))
.unwrap_or_else(|| "null".into()),
);
}
}
let entries: Vec<String> = model
.fields
.iter()
.enumerate()
.map(|(idx, field)| {
format!(
"{}: {}",
field.name,
values[idx].as_deref().unwrap_or("null")
)
})
.collect();
Some(format!("{{{}}}", entries.join(", ")))
}
fn bound_content(expr: &IrExpr, loop_var: Option<&str>, program: &IrProgram) -> String {
let fallback = esc(&expr_to_display(expr));
if matches!(expr, IrExpr::Literal(_) | IrExpr::ConstructorCall { .. }) {
return fallback;
}
match expr_to_js(expr, loop_var, program) {
Some(js) => format!("<span data-bind=\"{js}\">{fallback}</span>"),
None => fallback,
}
}
fn text_style(t: &IrText) -> String {
let mut style = String::new();
if let Some(size) = t.font_size {
let _ = write!(style, "font-size:{size}px;");
}
if matches!(t.font_weight, Some(FontWeight::Bold)) {
style.push_str("font-weight:700;");
}
if let Some(color) = &t.color {
let _ = write!(style, "color:{};", ir_css_color(color));
}
if let Some(align) = t.align {
let a = match align {
TextAlign::Left => "left",
TextAlign::Center => "center",
TextAlign::Right => "right",
};
let _ = write!(style, "text-align:{a};");
}
if let Some(lines) = t.max_lines {
let _ = write!(
style,
"display:-webkit-box;-webkit-line-clamp:{lines};-webkit-box-orient:vertical;overflow:hidden;"
);
}
style
}
fn ir_css_color(color: &IrColor) -> String {
match color {
IrColor::Solid(c) => css_color(c),
IrColor::Conditional { then_color, .. } => css_color(then_color),
}
}
fn css_color(color: &Color) -> String {
match color {
Color::Hex(hex) => format!("#{hex}"),
Color::Named(name) => match name.as_str() {
"gray" | "grey" => "#8e8e93".to_string(),
"primary" => "#0a84ff".to_string(),
"secondary" => "#5e5ce6".to_string(),
other => other.to_string(),
},
}
}
fn expr_to_display(expr: &IrExpr) -> String {
match expr {
IrExpr::Literal(lit) => match &lit.value {
LiteralValue::Number(n) => {
if *n == (*n as i64) as f64 {
format!("{}", *n as i64)
} else {
format!("{n}")
}
}
LiteralValue::Text(s) => s.clone(),
LiteralValue::Boolean(b) => b.to_string(),
LiteralValue::Null => String::new(),
},
IrExpr::Variable { name, .. } => format!("{{{name}}}"),
IrExpr::PropertyAccess {
object, property, ..
} => format!("{{{object}.{property}}}"),
IrExpr::PropertyChain { parts, .. } => format!("{{{}}}", parts.join(".")),
IrExpr::StringInterpolation { parts, .. } => {
let mut out = String::new();
for part in parts {
match part {
StringPart::Literal(s) => out.push_str(s),
StringPart::Expr(e) => out.push_str(&expr_to_display(e)),
}
}
out
}
IrExpr::BinaryOp {
op, left, right, ..
} => format!("{} {op} {}", expr_to_display(left), expr_to_display(right)),
IrExpr::UnaryOp { op, operand, .. } => {
let e = expr_to_display(operand);
match op {
UnaryOp::Negate => format!("-{e}"),
UnaryOp::Not => format!("!{e}"),
}
}
IrExpr::FunctionCall { method, .. } => format!("{{{method}(…)}}"),
IrExpr::ConstructorCall { type_name, .. } => format!("{{{type_name}}}"),
IrExpr::ListLiteral { .. } => "[…]".to_string(),
IrExpr::Translation { key, .. } => format!("{{{key}}}"),
IrExpr::Conditional {
condition,
then_expr,
else_expr,
..
} => format!(
"{} ? {} : {}",
expr_to_display(condition),
expr_to_display(then_expr),
expr_to_display(else_expr)
),
IrExpr::IndexAccess { target, index, .. } => {
format!("{}[{}]", expr_to_display(target), expr_to_display(index))
}
}
}
fn esc(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
const CSS: &str = r#"
:root {
--bg: #0d0f14;
--frame: #16181f;
--frame-edge: #2a2d37;
--bar: #1d2029;
--text: #f2f3f5;
--muted: #8e8e93;
--accent: #0a84ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.page-head {
text-align: center;
padding: 32px 16px 8px;
}
.page-head h1 { margin: 0; font-size: 26px; font-weight: 700; }
.subtitle { color: var(--muted); font-size: 13px; margin: 6px 0 0; }
.subtitle code { color: var(--accent); }
.frames {
display: flex;
flex-wrap: wrap;
gap: 32px;
justify-content: center;
align-items: flex-start;
padding: 32px 16px 64px;
}
.empty { color: var(--muted); text-align: center; width: 100%; }
.phone {
position: relative;
width: 390px;
background: var(--frame);
border: 1px solid var(--frame-edge);
border-radius: 40px;
box-shadow: 0 24px 60px rgba(0,0,0,0.55);
overflow: hidden;
padding-top: 12px;
}
.notch {
width: 140px;
height: 26px;
background: #000;
border-radius: 0 0 16px 16px;
margin: 0 auto 4px;
}
.screen-bar {
background: var(--bar);
text-align: center;
font-weight: 600;
font-size: 15px;
padding: 12px;
border-bottom: 1px solid var(--frame-edge);
}
.screen-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
min-height: 520px;
}
.el { color: var(--text); }
.text { margin: 0; font-size: 16px; line-height: 1.35; }
.btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 12px;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
cursor: default;
text-align: center;
}
.image {
display: flex;
align-items: center;
justify-content: center;
min-width: 64px;
min-height: 64px;
background: #23262f;
border: 1px dashed var(--frame-edge);
border-radius: 12px;
color: var(--muted);
font-size: 12px;
padding: 8px;
}
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 16px;
}
.switch {
display: inline-block;
width: 44px;
height: 26px;
background: var(--frame-edge);
border-radius: 13px;
position: relative;
flex: 0 0 auto;
transition: background 0.16s ease;
}
.switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
transition: transform 0.16s ease;
}
.toggle.is-on .switch { background: var(--accent); }
.toggle.is-on .switch::after { transform: translateX(18px); }
.field {
background: #23262f;
border: 1px solid var(--frame-edge);
border-radius: 10px;
padding: 12px;
font-size: 16px;
color: var(--text);
width: 100%;
}
.field::placeholder { color: var(--muted); }
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--frame-edge);
border-top-color: var(--accent);
border-radius: 50%;
align-self: center;
}
.divider { border: none; border-top: 1px solid var(--frame-edge); width: 100%; margin: 4px 0; }
.spacer { flex: 1 1 auto; min-height: 16px; }
.column { display: flex; flex-direction: column; }
.row { display: flex; flex-direction: row; align-items: center; }
.scroll { display: flex; flex-direction: column; gap: 10px; max-height: 360px; overflow-y: auto; }
.card {
background: #1b1e27;
border: 1px solid var(--frame-edge);
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
display: flex;
flex-direction: column;
gap: 8px;
}
.section {
display: flex;
flex-direction: column;
gap: 6px;
background: #1b1e27;
border: 1px solid var(--frame-edge);
border-radius: 12px;
padding: 12px;
}
.section-title {
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 12px;
color: var(--muted);
}
.form { display: flex; flex-direction: column; gap: 10px; }
.form-error { min-height: 16px; margin: -6px 0 0; color: #ff6b6b; font-size: 12px; }
.placeholder {
border: 1px dashed var(--accent);
border-radius: 10px;
padding: 12px;
color: var(--muted);
font-size: 13px;
text-align: center;
}
"#;
const WEB_CSS: &str = r#"
body.web-app { display: flex; justify-content: center; align-items: flex-start; padding: 32px 16px; min-height: 100vh; }
.web-app .btn { cursor: pointer; }
.web-app .screen-stage { padding: 18px; min-height: 520px; }
.web-app .screen { display: none; }
.web-app .screen.active { display: flex; flex-direction: column; gap: 10px; }
"#;
fn web_js(state_init: &str) -> String {
const TPL: &str = r#"<script>
(function () {
var state = {};
__STATE_INIT__
var bar = document.getElementById('screen-bar');
var stack = [];
function activeName() {
var el = document.querySelector('.screen.active');
return el ? el.getAttribute('data-screen') : null;
}
function show(name) {
var secs = document.querySelectorAll('.screen');
for (var i = 0; i < secs.length; i++) secs[i].classList.remove('active');
var el = document.querySelector('.screen[data-screen="' + name + '"]');
if (el) el.classList.add('active');
if (bar) bar.textContent = name;
}
// Evaluate a data-bind expression against state (best-effort; '' on error).
function val(js) {
try { return (new Function('state', 'return (' + js + ');'))(state); }
catch (e) { return ''; }
}
function itemVal(js, item) {
try { return (new Function('state', '__it', 'return (' + js + ');'))(state, item); }
catch (e) { return ''; }
}
function syncToggles(root, item) {
var toggles = root.querySelectorAll('[data-bind-toggle]');
for (var i = 0; i < toggles.length; i++) {
var js = toggles[i].getAttribute('data-bind-toggle');
if (item === undefined && js.indexOf('__it') !== -1) continue;
var on = item === undefined ? !!val(js) : !!itemVal(js, item);
toggles[i].classList.toggle('is-on', on);
toggles[i].classList.toggle('is-off', !on);
toggles[i].setAttribute('aria-checked', on ? 'true' : 'false');
}
}
function validateForm(form) {
var ok = true;
var fields = form.querySelectorAll('[data-form-field]');
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
var name = field.getAttribute('data-form-field');
var value = field.value || '';
var msg = '';
if (field.getAttribute('data-required') && !value.trim()) msg = 'Required';
else if (field.getAttribute('data-email') && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) msg = 'Invalid email';
var err = form.querySelector('[data-error-for="' + name + '"]');
if (err) err.textContent = msg;
if (msg) ok = false;
}
return ok;
}
function formPayload(form) {
var out = {};
var fields = form.querySelectorAll('[data-form-field]');
for (var i = 0; i < fields.length; i++) out[fields[i].getAttribute('data-form-field')] = fields[i].value || '';
return out;
}
function submitForm(form, endpoint) {
// ponytail: preview-grade POST; add status/error callbacks when the DSL has them.
fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formPayload(form)) })
.catch(function (e) { console.warn('nativ submit failed', e); });
}
function render() {
var binds = document.querySelectorAll('[data-bind]');
for (var i = 0; i < binds.length; i++) {
binds[i].textContent = val(binds[i].getAttribute('data-bind'));
}
var ifs = document.querySelectorAll('[data-bind-if]');
for (var j = 0; j < ifs.length; j++) {
ifs[j].style.display = val(ifs[j].getAttribute('data-bind-if')) ? '' : 'none';
}
renderEach();
syncToggles(document);
}
// Clone each `<template data-each-tpl>` once per real list item, evaluating
// its bindings/conditionals with `__it` bound to the item.
function renderEach() {
var boxes = document.querySelectorAll('[data-each]');
for (var i = 0; i < boxes.length; i++) {
var box = boxes[i];
var tpl = box.nextElementSibling;
if (!tpl || tpl.tagName !== 'TEMPLATE') continue;
var list = val(box.getAttribute('data-each')) || [];
box.innerHTML = '';
for (var k = 0; k < list.length; k++) {
var node = tpl.content.cloneNode(true);
var bs = node.querySelectorAll('[data-bind]');
for (var j = 0; j < bs.length; j++) {
try { bs[j].textContent = (new Function('state', '__it', 'return (' + bs[j].getAttribute('data-bind') + ');'))(state, list[k]); }
catch (e) {}
}
var ci = node.querySelectorAll('[data-bind-if]');
for (var m = 0; m < ci.length; m++) {
try { ci[m].style.display = (new Function('state', '__it', 'return (' + ci[m].getAttribute('data-bind-if') + ');'))(state, list[k]) ? '' : 'none'; }
catch (e) {}
}
syncToggles(node, list[k]);
box.appendChild(node);
}
}
}
// Run data-on-tap statements against state, then re-render.
function run(js) {
try { (new Function('state', js))(state); } catch (e) {}
render();
}
document.addEventListener('click', function (e) {
var submit = e.target.closest('[data-submit-form]');
if (submit) {
var form = submit.closest('.form');
if (form && !validateForm(form)) { e.preventDefault(); return; }
var endpoint = submit.getAttribute('data-submit-endpoint');
if (form && endpoint) submitForm(form, endpoint);
var submitTap = submit.getAttribute('data-submit-tap');
if (submitTap) run(submitTap); else render();
}
var tap = e.target.closest('[data-on-tap]');
if (tap) run(tap.getAttribute('data-on-tap'));
var g = e.target.closest('[data-goto]');
if (g) {
var name = g.getAttribute('data-goto');
var cur = activeName();
if (cur && cur !== name) stack.push(cur);
show(name);
render();
return;
}
var b = e.target.closest('[data-back]');
if (b && stack.length) { show(stack.pop()); render(); }
});
// Two-way textfield binding: typing writes back to state, then re-renders.
document.addEventListener('input', function (e) {
var t = e.target.closest('[data-bind-input]');
if (t) {
try { (new Function('state', 'v', t.getAttribute('data-bind-input') + ' = v;'))(state, t.value); }
catch (x) {}
render();
}
});
render();
})();
</script>
"#;
TPL.replace("__STATE_INIT__", state_init)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/valid")
.join(name)
}
fn render_fixture(name: &str) -> String {
let source = std::fs::read_to_string(fixture(name)).expect("fixture readable");
let ast = nativ_compiler::parse(&source).expect("fixture parses");
let program = lower_ast(&ast).expect("fixture lowers");
render_preview(name, &program)
}
#[test]
fn renders_self_contained_document_with_one_frame_per_screen() {
let html = render_fixture("ui_simple_elements.nativ");
assert!(html.starts_with("<!DOCTYPE html>"), "doc preamble missing");
assert!(html.contains("<style>"), "inline CSS missing");
assert_eq!(html.matches("class=\"phone\"").count(), 1);
assert!(html.contains("SimpleElements"), "screen name missing");
assert!(html.contains("class=\"el spinner\""));
assert!(html.contains("class=\"el divider\""));
assert!(html.contains("class=\"el spacer\""));
}
#[test]
fn renders_card_with_text_content_and_modifiers() {
let html = render_fixture("layout_card.nativ");
assert!(html.contains("class=\"el card\""), "card missing");
assert!(html.contains("Styled card"), "card text missing");
assert!(html.contains("font-weight:700"), "bold modifier missing");
}
#[test]
fn renders_each_loop_as_repeated_placeholders() {
let html = render_fixture("control_for_each.nativ");
assert_eq!(html.matches("class=\"el row\"").count(), 4);
assert_eq!(html.matches("class=\"el toggle\"").count(), 4);
assert!(html.contains("{todo.title}"), "loop item text missing");
}
#[test]
fn renders_button_with_label() {
let html = render_fixture("ui_button_basic.nativ");
assert!(html.contains("<button class=\"el btn\""), "button missing");
assert!(html.contains("Increment"), "button label missing");
}
#[test]
fn escapes_html_special_characters() {
assert_eq!(esc("a<b>&\"'"), "a<b>&"'");
}
#[test]
fn resolves_a_file_path_directly() {
let path = fixture("ui_simple_elements.nativ");
let resolved = resolve_input(&path).expect("file resolves");
assert_eq!(resolved, path);
}
#[test]
fn resolves_a_directory_path() {
let tmp = tempfile::tempdir().unwrap();
let resolved = resolve_input(tmp.path()).expect("directory resolves");
assert_eq!(resolved, tmp.path());
}
#[test]
fn missing_path_is_an_error() {
let err = resolve_input(Path::new("does-not-exist.nativ")).unwrap_err();
assert!(err.to_string().contains("not found"), "{err}");
}
#[test]
fn missing_directory_is_an_error() {
let err = resolve_input(Path::new("nonexistent-dir")).unwrap_err();
assert!(err.to_string().contains("not found"), "{err}");
}
#[test]
fn render_to_string_returns_html_and_screen_count() {
let (html, screens) = render_to_string(&fixture("ui_simple_elements.nativ")).unwrap();
assert!(html.starts_with("<!DOCTYPE html>"));
assert_eq!(screens, 1, "expected one screen");
assert_eq!(html.matches("class=\"phone\"").count(), screens);
}
#[test]
fn render_to_path_writes_a_self_contained_file() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("out.preview.html");
let abs = render_to_path(&fixture("ui_simple_elements.nativ"), &out).unwrap();
let written = std::fs::read_to_string(&out).expect("preview written");
assert!(
written.starts_with("<!DOCTYPE html>"),
"preview not written"
);
assert!(written.contains("<style>"), "inline CSS missing");
assert!(
abs.ends_with("out.preview.html"),
"unexpected path: {abs:?}"
);
}
#[test]
fn render_to_path_reports_source_location_on_parse_error() {
let tmp = tempfile::tempdir().unwrap();
let bad = tmp.path().join("bad.nativ");
std::fs::write(&bad, "screen Bad:\n\ttext \"tabs\"\n").unwrap();
let out = tmp.path().join("out.html");
let err = render_to_path(&bad, &out).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("bad.nativ"),
"error should name the file: {msg}"
);
assert!(
!out.exists(),
"no output should be written on a parse error"
);
}
#[test]
fn default_out_path_for_file_uses_stem() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("app.nativ");
std::fs::write(&file, "screen A:\n text \"a\"\n").unwrap();
let out = default_out_path(&file);
assert_eq!(out, tmp.path().join("app.preview.html"));
}
#[test]
fn default_out_path_for_directory_uses_dir_name() {
let out = default_out_path(Path::new("my-project"));
assert_eq!(out, PathBuf::from("my-project/my-project.preview.html"));
}
fn scaffold_project(tmp: &Path) {
let src = tmp.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("app.nativ"),
"app MyApp:\n name: \"MyApp\"\n display: \"My App\"\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n text \"Hello from Home\"\n",
)
.unwrap();
std::fs::write(
src.join("profile.nativ"),
"screen Profile:\n text \"Hello from Profile\"\n",
)
.unwrap();
}
#[test]
fn discover_nativ_files_finds_all_nativ_files_in_src() {
let tmp = tempfile::tempdir().unwrap();
scaffold_project(tmp.path());
let files = discover_nativ_files(tmp.path()).expect("discover succeeds");
assert_eq!(files.len(), 3, "expected 3 .nativ files");
assert!(files[0].ends_with("app.nativ"));
assert!(files[1].ends_with("home.nativ"));
assert!(files[2].ends_with("profile.nativ"));
}
#[test]
fn discover_nativ_files_returns_empty_for_empty_directory() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("src")).unwrap();
let files = discover_nativ_files(tmp.path()).expect("discover succeeds");
assert!(files.is_empty(), "expected no files");
}
#[test]
fn render_to_string_with_directory_renders_merged_screens() {
let tmp = tempfile::tempdir().unwrap();
scaffold_project(tmp.path());
let (html, screens) = render_to_string(tmp.path()).expect("directory renders successfully");
assert!(html.starts_with("<!DOCTYPE html>"), "doc preamble missing");
assert_eq!(screens, 2, "expected 2 screens from merged project");
assert_eq!(html.matches("class=\"phone\"").count(), 2);
assert!(html.contains("Home"), "Home screen name missing");
assert!(html.contains("Profile"), "Profile screen name missing");
assert!(
html.contains("Hello from Home"),
"Home text content missing"
);
assert!(
html.contains("Hello from Profile"),
"Profile text content missing"
);
let dir_name = tmp.path().file_name().unwrap().to_string_lossy();
assert!(
html.contains(&*dir_name),
"directory name should appear in title"
);
}
#[test]
fn render_to_string_with_directory_errors_on_no_nativ_files() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("src")).unwrap();
let err = render_to_string(tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("no .nativ files found"),
"error should mention missing files: {msg}"
);
}
#[test]
fn render_to_string_with_directory_errors_on_parse_failure() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("bad.nativ"), "screen Bad:\n\ttext \"tabs\"\n").unwrap();
let err = render_to_string(tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("bad.nativ"),
"error should name the bad file: {msg}"
);
}
#[test]
fn render_to_path_with_directory_writes_preview_file() {
let tmp = tempfile::tempdir().unwrap();
scaffold_project(tmp.path());
let out = tmp.path().join("project.preview.html");
let abs = render_to_path(tmp.path(), &out).expect("directory renders");
assert!(
abs.ends_with("project.preview.html"),
"unexpected path: {abs:?}"
);
let written = std::fs::read_to_string(&out).expect("preview written");
assert!(written.starts_with("<!DOCTYPE html>"));
assert!(written.contains("Hello from Home"));
}
#[test]
fn collect_nativ_files_walks_nested_directories() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("src/screens")).unwrap();
std::fs::write(
tmp.path().join("src/app.nativ"),
"screen A:\n text \"a\"\n",
)
.unwrap();
std::fs::write(
tmp.path().join("src/screens/home.nativ"),
"screen B:\n text \"b\"\n",
)
.unwrap();
let mut files = Vec::new();
collect_nativ_files(tmp.path(), &mut files).unwrap();
assert_eq!(files.len(), 2, "expected 2 .nativ files nested");
}
#[test]
fn nativ_sources_trigger_rerender() {
for path in ["app.nativ", "src/app.nativ", "src/screens/Home.nativ"] {
assert!(triggers_rerender(Path::new(path)), "{path} should trigger");
}
}
#[test]
fn output_and_temp_files_do_not_trigger_rerender() {
for path in [
"app.preview.html", "src/app.nativ~", "src/.app.nativ.swp", "src/app.nativ.tmp", "README.md",
"nativ.toml", ] {
assert!(
!triggers_rerender(Path::new(path)),
"{path} should not trigger"
);
}
}
#[test]
fn debounced_batch_rerenders_only_when_a_nativ_path_is_present() {
let save_burst = [
PathBuf::from("src/.app.nativ.tmp123"),
PathBuf::from("src/app.nativ"),
];
assert!(save_burst.iter().any(|p| triggers_rerender(p)));
let output_noise = [PathBuf::from("app.preview.html")];
assert!(!output_noise.iter().any(|p| triggers_rerender(p)));
}
fn parse_preview_args(argv: &[&str]) -> PreviewArgs {
use clap::Parser;
#[derive(clap::Parser)]
struct Probe {
#[command(flatten)]
args: PreviewArgs,
}
let mut full = vec!["preview"];
full.extend_from_slice(argv);
Probe::parse_from(full).args
}
#[test]
fn watch_flag_defaults_off_and_path_defaults_to_cwd() {
let args = parse_preview_args(&[]);
assert!(!args.watch);
assert!(!args.open);
assert!(!args.serve);
assert_eq!(args.port, 4173);
assert_eq!(args.path, ".");
assert!(args.out.is_none());
}
#[test]
fn parses_watch_open_and_out_flags_together() {
let args = parse_preview_args(&[
"app.nativ",
"--watch",
"--open",
"--serve",
"--port",
"0",
"--out",
"p.html",
]);
assert_eq!(args.path, "app.nativ");
assert!(args.watch);
assert!(args.open);
assert!(args.serve);
assert_eq!(args.port, 0);
assert_eq!(args.out.as_deref(), Some("p.html"));
}
#[test]
fn preview_server_serves_reloadable_html_and_version() {
let state = PreviewServerState::new("<html><body>Hi</body></html>".into());
let addr = start_preview_server(0, state.clone()).expect("server starts");
let html = http_get(addr, "/");
assert!(html.contains("Hi"), "html response: {html}");
assert!(html.contains("/__nativ_version"), "html response: {html}");
let version = http_get(addr, "/__nativ_version");
assert!(
version.ends_with("\r\n\r\n1"),
"version response: {version}"
);
state.update("<html><body>Bye</body></html>".into());
let html = http_get(addr, "/");
assert!(html.contains("Bye"), "html response: {html}");
assert!(!html.contains("Hi"), "html response: {html}");
let version = http_get(addr, "/__nativ_version");
assert!(
version.ends_with("\r\n\r\n2"),
"version response: {version}"
);
}
#[test]
fn preview_server_returns_404_for_unknown_path() {
let state = PreviewServerState::new("<html><body>Hi</body></html>".into());
let addr = start_preview_server(0, state).expect("server starts");
let response = http_get(addr, "/missing.css");
assert!(response.starts_with("HTTP/1.1 404"), "response: {response}");
}
#[test]
fn inject_live_reload_inserts_polling_script_before_body_close() {
let html = inject_live_reload("<html><body>Hi</body></html>");
assert!(html.contains("/__nativ_version"));
assert!(html.contains("location.reload()"));
assert!(html.find("<script>").unwrap() < html.find("</body>").unwrap());
}
fn http_get(addr: std::net::SocketAddr, path: &str) -> String {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], addr.port()));
for _ in 0..50 {
let mut stream = std::net::TcpStream::connect(addr).expect("connect");
if write!(stream, "GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n").is_err() {
std::thread::sleep(Duration::from_millis(10));
continue;
}
if stream.flush().is_err() {
std::thread::sleep(Duration::from_millis(10));
continue;
}
let mut bytes = Vec::new();
match stream.read_to_end(&mut bytes) {
Ok(_) => {}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted
) => {}
Err(e) => panic!("read: {e}"),
}
if !bytes.is_empty() {
return String::from_utf8(bytes).expect("utf-8 response");
}
std::thread::sleep(Duration::from_millis(10));
}
String::new()
}
}