dreamstack/compiler/ds-cli/src/main.rs
enzotar a290bc1891 feat: container variant props, 11-component registry, rich dashboard
Parser:
- column/row/stack now parse trailing { props } after ]
- Enables: column [...] { variant: "card" }

Codegen:
- Container props dispatch: variant, class, click, style, layout
- variant_to_css() maps (tag, variant) → CSS class
- variant_map_js() for dynamic variants via inline JS map
- 230+ lines design system CSS (button/badge/card/dialog/toast/
  progress/alert/separator/toggle/avatar/stat)

Registry (11 components):
- button, input, card, badge, dialog, toast
- NEW: progress, alert, separator, toggle, avatar
- All embedded via include_str! for offline use

Examples:
- showcase.ds: component gallery with variant prop
- dashboard.ds: admin dashboard with glassmorphism cards
2026-02-26 13:58:33 -08:00

1826 lines
61 KiB
Rust

/// DreamStack CLI — the compiler command-line interface.
///
/// Usage:
/// dreamstack build <file.ds> — compile to HTML+JS
/// dreamstack dev <file.ds> — dev server with hot reload
/// dreamstack check <file.ds> — analyze without emitting
use clap::{Parser, Subcommand};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, atomic::{AtomicU64, Ordering}};
use std::time::{Duration, Instant};
#[derive(Parser)]
#[command(name = "dreamstack")]
#[command(about = "The DreamStack UI compiler", version = "0.1.0")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Compile a .ds file to HTML+JS
Build {
/// Input .ds file
file: PathBuf,
/// Output directory (default: dist/)
#[arg(short, long, default_value = "dist")]
output: PathBuf,
},
/// Start a dev server with hot reload
Dev {
/// Input .ds file
file: PathBuf,
/// Port to serve on
#[arg(short, long, default_value_t = 3000)]
port: u16,
},
/// Type-check and analyze without compiling
Check {
/// Input .ds file
file: PathBuf,
},
/// Compile and stream a .ds file via bitstream relay
Stream {
/// Input .ds file
file: PathBuf,
/// WebSocket relay URL
#[arg(short, long, default_value = "ws://localhost:9100")]
relay: String,
/// Stream mode: pixel | delta | signal
#[arg(short, long, default_value = "signal")]
mode: String,
/// Port to serve the source page on
#[arg(short, long, default_value_t = 3000)]
port: u16,
},
/// Launch the interactive playground with Monaco editor
Playground {
/// Optional .ds file to pre-load
file: Option<PathBuf>,
/// Port to serve on
#[arg(short, long, default_value_t = 4000)]
port: u16,
},
/// Add a component from the DreamStack registry
Add {
/// Component name (e.g., button, card, dialog) or --list to show available
name: Option<String>,
/// List all available components
#[arg(long)]
list: bool,
/// Add all registry components
#[arg(long)]
all: bool,
},
/// Convert a React/TSX component to DreamStack .ds format
Convert {
/// Input .tsx file path, or component name with --shadcn
name: String,
/// Fetch from shadcn/ui registry instead of local file
#[arg(long)]
shadcn: bool,
/// Output file path (default: stdout)
#[arg(short, long)]
output: Option<PathBuf>,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Build { file, output } => cmd_build(&file, &output),
Commands::Dev { file, port } => cmd_dev(&file, port),
Commands::Check { file } => cmd_check(&file),
Commands::Stream { file, relay, mode, port } => cmd_stream(&file, &relay, &mode, port),
Commands::Playground { file, port } => cmd_playground(file.as_deref(), port),
Commands::Add { name, list, all } => cmd_add(name, list, all),
Commands::Convert { name, shadcn, output } => cmd_convert(&name, shadcn, output.as_deref()),
}
}
fn compile(source: &str, base_dir: &Path) -> Result<String, String> {
// 1. Lex
let mut lexer = ds_parser::Lexer::new(source);
let tokens = lexer.tokenize();
// Check for lexer errors
for tok in &tokens {
if let ds_parser::TokenKind::Error(msg) = &tok.kind {
return Err(format!("Lexer error at line {}: {}", tok.line, msg));
}
}
// 2. Parse
let mut parser = ds_parser::Parser::new(tokens);
let mut program = parser.parse_program().map_err(|e| e.to_string())?;
// 3. Resolve imports — inline exported declarations from imported files
resolve_imports(&mut program, base_dir)?;
// 4. Analyze
let graph = ds_analyzer::SignalGraph::from_program(&program);
let views = ds_analyzer::SignalGraph::analyze_views(&program);
// 5. Codegen
let html = ds_codegen::JsEmitter::emit_html(&program, &graph, &views);
Ok(html)
}
/// Resolve `import { X, Y } from "./file"` by parsing the imported file
/// and inlining the matching `export`ed declarations.
fn resolve_imports(program: &mut ds_parser::Program, base_dir: &Path) -> Result<(), String> {
use std::collections::HashSet;
let mut imported_decls = Vec::new();
let mut seen_files: HashSet<PathBuf> = HashSet::new();
for decl in &program.declarations {
if let ds_parser::Declaration::Import(import) = decl {
// Resolve the file path relative to base_dir
let mut import_path = base_dir.join(&import.source);
if !import_path.extension().map_or(false, |e| e == "ds") {
import_path.set_extension("ds");
}
let import_path = import_path.canonicalize().unwrap_or(import_path.clone());
if seen_files.contains(&import_path) {
continue; // Skip duplicate imports
}
seen_files.insert(import_path.clone());
// Read and parse the imported file
let imported_source = fs::read_to_string(&import_path)
.map_err(|e| format!("Cannot import '{}': {}", import.source, e))?;
let mut lexer = ds_parser::Lexer::new(&imported_source);
let tokens = lexer.tokenize();
for tok in &tokens {
if let ds_parser::TokenKind::Error(msg) = &tok.kind {
return Err(format!("Lexer error in '{}' at line {}: {}", import.source, tok.line, msg));
}
}
let mut parser = ds_parser::Parser::new(tokens);
let mut imported_program = parser.parse_program()
.map_err(|e| format!("Parse error in '{}': {}", import.source, e))?;
// Recursively resolve imports in the imported file
let imported_dir = import_path.parent().unwrap_or(base_dir);
resolve_imports(&mut imported_program, imported_dir)?;
// Extract matching exports
let names: HashSet<&str> = import.names.iter().map(|s| s.as_str()).collect();
for d in &imported_program.declarations {
match d {
ds_parser::Declaration::Export(name, inner) if names.contains(name.as_str()) => {
imported_decls.push(*inner.clone());
}
// Also include non-exported decls that exports depend on
// (for now, include all let decls from the imported file)
ds_parser::Declaration::Let(_) => {
imported_decls.push(d.clone());
}
_ => {}
}
}
}
}
// Remove Import declarations and prepend imported decls
program.declarations.retain(|d| !matches!(d, ds_parser::Declaration::Import(_)));
let mut merged = imported_decls;
merged.append(&mut program.declarations);
program.declarations = merged;
Ok(())
}
fn cmd_build(file: &Path, output: &Path) {
println!("🔨 DreamStack build");
println!(" source: {}", file.display());
let source = match fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("❌ Could not read {}: {}", file.display(), e);
std::process::exit(1);
}
};
let base_dir = file.parent().unwrap_or(Path::new("."));
match compile(&source, base_dir) {
Ok(html) => {
fs::create_dir_all(output).unwrap();
let out_path = output.join("index.html");
fs::write(&out_path, &html).unwrap();
println!(" output: {}", out_path.display());
println!("✅ Build complete! ({} bytes)", html.len());
println!();
println!(" Open in browser:");
println!(" file://{}", fs::canonicalize(&out_path).unwrap().display());
}
Err(e) => {
eprintln!("❌ Compile error: {e}");
std::process::exit(1);
}
}
}
/// HMR client script injected into every page served by `dreamstack dev`.
/// Uses Server-Sent Events to receive reload notifications from the dev server.
const HMR_CLIENT_SCRIPT: &str = r#"
<script>
// ── DreamStack HMR (poll-based) ─────────────
(function() {
let currentVersion = null;
let polling = false;
async function poll() {
if (polling) return;
polling = true;
try {
const res = await fetch('/__hmr');
const version = await res.text();
if (currentVersion === null) {
currentVersion = version;
console.log('[DS HMR] 🟢 watching (v' + version + ')');
} else if (version !== currentVersion) {
console.log('[DS HMR] 🔄 change detected (v' + currentVersion + ' → v' + version + '), reloading...');
location.reload();
return;
}
} catch(e) {
// server down — retry silently
}
polling = false;
}
setInterval(poll, 500);
poll();
})();
</script>
"#;
fn inject_hmr(html: &str) -> String {
// Inject the HMR script just before </body>
if let Some(pos) = html.rfind("</body>") {
format!("{}{}{}", &html[..pos], HMR_CLIENT_SCRIPT, &html[pos..])
} else {
// No </body> tag — just append
format!("{html}{HMR_CLIENT_SCRIPT}")
}
}
fn cmd_dev(file: &Path, port: u16) {
use notify::{Watcher, RecursiveMode};
use std::sync::mpsc;
use std::thread;
println!("🚀 DreamStack dev server");
println!(" watching: {}", file.display());
println!(" serving: http://localhost:{port}");
println!();
// Shared state: compiled HTML + version counter
let version = Arc::new(AtomicU64::new(1));
let compiled_html = Arc::new(Mutex::new(String::new()));
// Initial compile
let source = match fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("❌ Could not read {}: {}", file.display(), e);
std::process::exit(1);
}
};
let start = Instant::now();
let base_dir = file.parent().unwrap_or(Path::new("."));
match compile(&source, base_dir) {
Ok(html) => {
let ms = start.elapsed().as_millis();
let html_with_hmr = inject_hmr(&html);
*compiled_html.lock().unwrap() = html_with_hmr;
println!("✅ Compiled in {ms}ms ({} bytes)", html.len());
}
Err(e) => {
eprintln!("⚠️ Compile error: {e}");
let error_html = format!(
r#"<!DOCTYPE html>
<html><head><meta charset="UTF-8"><style>
body {{ background: #0a0a0f; color: #ef4444; font-family: 'JetBrains Mono', monospace; padding: 40px; }}
pre {{ white-space: pre-wrap; line-height: 1.7; }}
h2 {{ color: #f87171; margin-bottom: 16px; }}
</style></head><body>
<h2>── COMPILE ERROR ──</h2>
<pre>{e}</pre>
</body></html>"#
);
*compiled_html.lock().unwrap() = inject_hmr(&error_html);
}
}
// ── File Watcher Thread ─────────────────────────
let file_path = fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
let watch_dir = file_path.parent().unwrap().to_path_buf();
let watch_file = file_path.clone();
let v_watcher = Arc::clone(&version);
let html_watcher = Arc::clone(&compiled_html);
thread::spawn(move || {
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
}).expect("Failed to create file watcher");
watcher.watch(&watch_dir, RecursiveMode::NonRecursive)
.expect("Failed to watch directory");
println!("👁 Watching {} for changes", watch_dir.display());
println!();
// Debounce: coalesce rapid events
let mut last_compile = Instant::now();
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
// Only recompile for .ds file changes
let dominated = event.paths.iter().any(|p| {
p == &watch_file ||
p.extension().map_or(false, |ext| ext == "ds")
});
if !dominated { continue; }
// Debounce: skip if less than 100ms since last compile
if last_compile.elapsed() < Duration::from_millis(100) {
continue;
}
// Recompile
if let Ok(src) = fs::read_to_string(&watch_file) {
let start = Instant::now();
match compile(&src, watch_file.parent().unwrap_or(Path::new("."))) {
Ok(html) => {
let ms = start.elapsed().as_millis();
let new_version = v_watcher.fetch_add(1, Ordering::SeqCst) + 1;
*html_watcher.lock().unwrap() = inject_hmr(&html);
println!("🔄 Recompiled in {ms}ms (v{new_version}, {} bytes)", html.len());
last_compile = Instant::now();
}
Err(e) => {
let new_version = v_watcher.fetch_add(1, Ordering::SeqCst) + 1;
let error_html = format!(
r#"<!DOCTYPE html>
<html><head><meta charset="UTF-8"><style>
body {{ background: #0a0a0f; color: #ef4444; font-family: 'JetBrains Mono', monospace; padding: 40px; }}
pre {{ white-space: pre-wrap; line-height: 1.7; }}
h2 {{ color: #f87171; margin-bottom: 16px; }}
</style></head><body>
<h2>── COMPILE ERROR ──</h2>
<pre>{e}</pre>
</body></html>"#
);
*html_watcher.lock().unwrap() = inject_hmr(&error_html);
eprintln!("❌ v{new_version}: {e}");
last_compile = Instant::now();
}
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
// No events — loop and check again
continue;
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
});
// ── HTTP Server ─────────────────────────────────
let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap();
println!("✅ Server running at http://localhost:{port}");
println!(" Press Ctrl+C to stop");
println!();
for request in server.incoming_requests() {
let url = request.url().to_string();
if url == "/__hmr" {
// Version endpoint for HMR polling
let v = version.load(Ordering::SeqCst);
let response = tiny_http::Response::from_string(format!("{v}"))
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/plain"[..],
).unwrap(),
)
.with_header(
tiny_http::Header::from_bytes(
&b"Cache-Control"[..],
&b"no-cache, no-store"[..],
).unwrap(),
);
let _ = request.respond(response);
} else {
// Serve the compiled HTML
let html = compiled_html.lock().unwrap().clone();
let response = tiny_http::Response::from_string(&html)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
).unwrap(),
);
let _ = request.respond(response);
}
}
}
fn cmd_check(file: &Path) {
println!("🔍 DreamStack check");
println!(" file: {}", file.display());
let source = match fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("❌ Could not read {}: {}", file.display(), e);
std::process::exit(1);
}
};
// Lex
let mut lexer = ds_parser::Lexer::new(&source);
let tokens = lexer.tokenize();
let mut errors = 0;
for tok in &tokens {
if let ds_parser::TokenKind::Error(msg) = &tok.kind {
eprintln!(" ❌ Lexer error at line {}: {}", tok.line, msg);
errors += 1;
}
}
// Parse
let mut parser = ds_parser::Parser::new(tokens);
let program = match parser.parse_program() {
Ok(p) => p,
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
};
// Analyze
let graph = ds_analyzer::SignalGraph::from_program(&program);
let views = ds_analyzer::SignalGraph::analyze_views(&program);
println!();
println!(" 📊 Signal Graph:");
for node in &graph.nodes {
let kind_str = match &node.kind {
ds_analyzer::SignalKind::Source => "source",
ds_analyzer::SignalKind::Derived => "derived",
ds_analyzer::SignalKind::Handler { .. } => "handler",
};
let deps: Vec<&str> = node.dependencies.iter().map(|d| d.signal_name.as_str()).collect();
if deps.is_empty() {
println!(" {} [{}]", node.name, kind_str);
} else {
println!(" {} [{}] ← depends on: {}", node.name, kind_str, deps.join(", "));
}
}
println!();
println!(" 🖼️ Views:");
for view in &views {
println!(" {} ({} bindings)", view.name, view.bindings.len());
for binding in &view.bindings {
match &binding.kind {
ds_analyzer::BindingKind::TextContent { signal } => {
println!(" 📝 text bound to: {signal}");
}
ds_analyzer::BindingKind::EventHandler { element_tag, event, .. } => {
println!("{element_tag}.{event}");
}
ds_analyzer::BindingKind::Conditional { condition_signals } => {
println!(" ❓ conditional on: {}", condition_signals.join(", "));
}
ds_analyzer::BindingKind::StaticContainer { kind, child_count } => {
println!(" 📦 {kind} ({child_count} children)");
}
ds_analyzer::BindingKind::StaticText { text } => {
println!(" 📄 static: \"{text}\"");
}
}
}
}
let topo = graph.topological_order();
println!();
println!(" 🔄 Propagation order: {:?}", topo.iter().map(|&id| &graph.nodes[id].name).collect::<Vec<_>>());
if errors == 0 {
println!();
println!("✅ No errors found");
} else {
println!();
eprintln!("{} error(s) found", errors);
std::process::exit(1);
}
}
fn cmd_stream(file: &Path, relay: &str, mode: &str, port: u16) {
println!("⚡ DreamStack stream");
println!(" source: {}", file.display());
println!(" relay: {}", relay);
println!(" mode: {}", mode);
println!(" port: {}", port);
println!();
let source = match fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("❌ Could not read {}: {}", file.display(), e);
std::process::exit(1);
}
};
// Inject stream declaration if not present
let stream_source = if source.contains("stream ") {
source
} else {
// Auto-inject a stream declaration for the first view
let view_name = {
let mut lexer = ds_parser::Lexer::new(&source);
let tokens = lexer.tokenize();
let mut parser = ds_parser::Parser::new(tokens);
if let Ok(program) = parser.parse_program() {
program.declarations.iter()
.find_map(|d| if let ds_parser::ast::Declaration::View(v) = d { Some(v.name.clone()) } else { None })
.unwrap_or_else(|| "main".to_string())
} else {
"main".to_string()
}
};
format!(
"{}\nstream {} on \"{}\" {{ mode: {} }}",
source, view_name, relay, mode
)
};
match compile(&stream_source, file.parent().unwrap_or(Path::new("."))) {
Ok(html) => {
let html_with_hmr = inject_hmr(&html);
println!("✅ Compiled with streaming enabled");
println!(" Open: http://localhost:{port}");
println!(" Relay: {relay}");
println!();
println!(" Make sure the relay is running:");
println!(" cargo run -p ds-stream");
println!();
// Serve the compiled page
let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap();
for request in server.incoming_requests() {
let response = tiny_http::Response::from_string(&html_with_hmr)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
).unwrap(),
);
let _ = request.respond(response);
}
}
Err(e) => {
eprintln!("❌ Compile error: {e}");
std::process::exit(1);
}
}
}
// ── Playground ──────────────────────────────────────────────
/// The playground HTML page with Monaco editor + live preview.
const PLAYGROUND_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DreamStack Playground</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0a0a12;
--surface: #12121e;
--surface-2: #1a1a2e;
--border: #2a2a3e;
--text: #e4e4ef;
--text-dim: #888899;
--accent: #818cf8;
--accent-glow: rgba(129,140,248,0.15);
--green: #34d399;
--red: #f87171;
--yellow: #fbbf24;
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; overflow: hidden; }
/* Header */
.header {
height: 52px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
z-index: 100;
}
.header .logo {
font-size: 16px;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
}
.header .sep { width: 1px; height: 24px; background: var(--border); }
.header .status {
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 8px;
}
.header .status .dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 6px rgba(52,211,153,0.5);
transition: background 0.3s, box-shadow 0.3s;
}
.header .status .dot.error {
background: var(--red);
box-shadow: 0 0 6px rgba(248,113,113,0.5);
}
.header .status .dot.compiling {
background: var(--yellow);
box-shadow: 0 0 6px rgba(251,191,36,0.5);
animation: pulse 0.6s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.header .actions { margin-left: auto; display: flex; gap: 8px; }
.header .btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.header .btn:hover { border-color: var(--accent); background: var(--accent-glow); }
.header .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.header .btn.primary:hover { opacity: 0.9; }
/* Layout */
.container {
display: flex;
height: calc(100vh - 52px);
}
.editor-pane {
width: 50%;
min-width: 300px;
position: relative;
border-right: 1px solid var(--border);
}
.preview-pane {
flex: 1;
position: relative;
background: #fff;
}
#previewRoot {
width: 100%;
height: 100%;
overflow: auto;
}
/* Resize handle */
.resize-handle {
position: absolute;
right: -3px;
top: 0;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 10;
background: transparent;
transition: background 0.2s;
}
.resize-handle:hover, .resize-handle.active {
background: var(--accent);
}
/* Error panel */
.error-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
max-height: 40%;
background: rgba(10,10,18,0.97);
border-top: 2px solid var(--red);
overflow-y: auto;
z-index: 20;
display: none;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 16px 20px;
color: var(--red);
line-height: 1.6;
white-space: pre-wrap;
}
.error-panel.visible { display: block; }
/* Monaco loader */
#editor { width: 100%; height: 100%; }
</style>
</head>
<body>
<div class="header">
<span class="logo">DreamStack</span>
<span class="sep"></span>
<div class="status">
<span class="dot" id="statusDot"></span>
<span id="statusText">Ready</span>
</div>
<div class="actions">
<button class="btn" onclick="formatCode()">Format</button>
<button class="btn primary" onclick="compileNow()">Compile ⌘↵</button>
</div>
</div>
<div class="container">
<div class="editor-pane" id="editorPane">
<div id="editor"></div>
<div class="resize-handle" id="resizeHandle"></div>
<div class="error-panel" id="errorPanel"></div>
</div>
<div class="preview-pane" id="previewPane">
<div id="previewRoot"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
<script>
// ── State ──
let editor;
let compileTimer = null;
const DEBOUNCE_MS = 400;
// ── Monaco Setup ──
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' }});
require(['vs/editor/editor.main'], function () {
// Register DreamStack language
monaco.languages.register({ id: 'dreamstack' });
monaco.languages.setMonarchTokensProvider('dreamstack', {
keywords: ['let','view','when','match','on','effect','perform','handle','import','export',
'if','else','every','type','where','stream','from','layout','component','for','in'],
typeKeywords: ['Int','Float','String','Bool','Signal','Derived','Array','Stream','View'],
operators: ['=','>','<','>=','<=','==','!=','+','-','*','/','%','|>','->','!','&&','||'],
tokenizer: {
root: [
[/\/\/.*$/, 'comment'],
[/"([^"\\]|\\.)*"/, 'string'],
[/\d+\.\d+/, 'number.float'],
[/\d+/, 'number'],
[/[a-zA-Z_]\w*/, {
cases: {
'@keywords': 'keyword',
'@typeKeywords': 'type',
'@default': 'identifier'
}
}],
[/[{}()\[\]]/, 'delimiter.bracket'],
[/[,;:]/, 'delimiter'],
[/[=><!+\-*\/%|&]+/, 'operator'],
[/\s+/, 'white'],
]
}
});
// Define DreamStack theme
monaco.editor.defineTheme('dreamstack-dark', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: '818cf8', fontStyle: 'bold' },
{ token: 'type', foreground: '34d399' },
{ token: 'string', foreground: 'fbbf24' },
{ token: 'number', foreground: 'f472b6' },
{ token: 'number.float', foreground: 'f472b6' },
{ token: 'comment', foreground: '555566', fontStyle: 'italic' },
{ token: 'operator', foreground: '94a3b8' },
{ token: 'delimiter', foreground: '64748b' },
{ token: 'identifier', foreground: 'e4e4ef' },
],
colors: {
'editor.background': '#0a0a12',
'editor.foreground': '#e4e4ef',
'editor.lineHighlightBackground': '#1a1a2e',
'editorLineNumber.foreground': '#3a3a4e',
'editorLineNumber.activeForeground': '#818cf8',
'editor.selectionBackground': '#818cf833',
'editorCursor.foreground': '#818cf8',
'editorIndentGuide.background': '#1e1e30',
}
});
// Create editor
editor = monaco.editor.create(document.getElementById('editor'), {
value: INITIAL_SOURCE,
language: 'dreamstack',
theme: 'dreamstack-dark',
fontFamily: "'JetBrains Mono', monospace",
fontSize: 14,
lineHeight: 24,
padding: { top: 16 },
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderLineHighlight: 'all',
cursorBlinking: 'smooth',
cursorSmoothCaretAnimation: 'on',
smoothScrolling: true,
tabSize: 2,
wordWrap: 'on',
automaticLayout: true,
});
// Auto-compile on change
editor.onDidChangeModelContent(() => {
clearTimeout(compileTimer);
compileTimer = setTimeout(compileNow, DEBOUNCE_MS);
});
// Keyboard shortcut: Cmd/Ctrl + Enter
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, compileNow);
// Initial compile
compileNow();
});
// ── Compile ──
let compiling = false;
async function compileNow() {
if (compiling || !editor) return;
compiling = true;
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
const errorPanel = document.getElementById('errorPanel');
dot.className = 'dot compiling';
text.textContent = 'Compiling...';
const source = editor.getValue();
const start = performance.now();
try {
const res = await fetch('/compile', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: source,
});
const ms = (performance.now() - start).toFixed(0);
const data = await res.json();
if (data.type === 'full') {
const root = document.getElementById('previewRoot');
// Parse HTML and extract body + scripts
const parser = new DOMParser();
const doc = parser.parseFromString(data.html, 'text/html');
// Attach or reuse shadow root
if (!root.shadowRoot) root.attachShadow({ mode: 'open' });
const shadow = root.shadowRoot;
shadow.innerHTML = '';
// Copy styles into shadow
doc.querySelectorAll('style').forEach(s => shadow.appendChild(s.cloneNode(true)));
// Copy body content into shadow
const wrapper = document.createElement('div');
wrapper.innerHTML = doc.body.innerHTML;
shadow.appendChild(wrapper);
// Execute scripts in main window context (where DS signals live)
doc.querySelectorAll('script').forEach(s => {
try { new Function(s.textContent)(); } catch(e) { console.warn('Script error:', e); }
});
dot.className = 'dot';
text.textContent = `Full compile ${ms}ms`;
errorPanel.classList.remove('visible');
} else if (data.type === 'patch') {
if (data.js && data.js.length > 0) {
try {
new Function(data.js)();
} catch (e) { console.warn('Patch eval error:', e); }
}
dot.className = 'dot';
text.textContent = `Patched ${ms}ms ⚡`;
errorPanel.classList.remove('visible');
} else if (data.type === 'error') {
dot.className = 'dot error';
text.textContent = `Error (${ms}ms)`;
errorPanel.textContent = data.message;
errorPanel.classList.add('visible');
}
} catch (e) {
dot.className = 'dot error';
text.textContent = 'Network error';
errorPanel.textContent = e.message;
errorPanel.classList.add('visible');
}
compiling = false;
}
function formatCode() {
if (editor) editor.getAction('editor.action.formatDocument')?.run();
}
// ── Resize Handle ──
const handle = document.getElementById('resizeHandle');
const editorPane = document.getElementById('editorPane');
let resizing = false;
handle.addEventListener('mousedown', (e) => {
resizing = true;
handle.classList.add('active');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!resizing) return;
const pct = (e.clientX / window.innerWidth) * 100;
const clamped = Math.max(25, Math.min(75, pct));
editorPane.style.width = clamped + '%';
});
window.addEventListener('mouseup', () => {
resizing = false;
handle.classList.remove('active');
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
</script>
</body>
</html>
"##;
fn cmd_playground(file: Option<&Path>, port: u16) {
println!("🎮 DreamStack Playground");
println!(" http://localhost:{port}");
println!();
// Load initial source from file or use default
let initial_source = if let Some(path) = file {
match fs::read_to_string(path) {
Ok(s) => {
println!(" loaded: {}", path.display());
s
}
Err(e) => {
eprintln!("⚠️ Could not read {}: {e}", path.display());
default_playground_source()
}
}
} else {
default_playground_source()
};
// Build the playground HTML with the initial source injected
let escaped_source = initial_source
.replace('\\', "\\\\")
.replace('`', "\\`")
.replace("${", "\\${");
let playground_html = PLAYGROUND_HTML.replace(
"INITIAL_SOURCE",
&format!("String.raw`{}`", escaped_source),
);
let server = tiny_http::Server::http(format!("0.0.0.0:{port}")).unwrap();
println!("✅ Playground running at http://localhost:{port}");
println!(" Press Ctrl+C to stop");
println!();
let base_dir = file.and_then(|f| f.parent()).unwrap_or(Path::new("."));
let base_dir = base_dir.to_path_buf();
let mut inc_compiler = ds_incremental::IncrementalCompiler::new();
for mut request in server.incoming_requests() {
let url = request.url().to_string();
if url == "/compile" && request.method() == &tiny_http::Method::Post {
// Read the body
let mut body = String::new();
let reader = request.as_reader();
match reader.read_to_string(&mut body) {
Ok(_) => {}
Err(e) => {
let resp = tiny_http::Response::from_string(format!("Read error: {e}"))
.with_status_code(400);
let _ = request.respond(resp);
continue;
}
}
let start = Instant::now();
match inc_compiler.compile(&body) {
ds_incremental::IncrementalResult::Full(html) => {
let ms = start.elapsed().as_millis();
println!(" ✅ full compile in {ms}ms ({} bytes)", html.len());
let json = format!(r#"{{"type":"full","html":{}}}"#, json_escape(&html));
let response = tiny_http::Response::from_string(&json)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json; charset=utf-8"[..],
).unwrap(),
)
.with_header(
tiny_http::Header::from_bytes(
&b"Access-Control-Allow-Origin"[..],
&b"*"[..],
).unwrap(),
);
let _ = request.respond(response);
}
ds_incremental::IncrementalResult::Patch(js) => {
let ms = start.elapsed().as_millis();
if js.is_empty() {
println!(" ⚡ unchanged ({ms}ms)");
} else {
println!(" ⚡ incremental patch in {ms}ms ({} bytes)", js.len());
}
let json = format!(r#"{{"type":"patch","js":{}}}"#, json_escape(&js));
let response = tiny_http::Response::from_string(&json)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json; charset=utf-8"[..],
).unwrap(),
)
.with_header(
tiny_http::Header::from_bytes(
&b"Access-Control-Allow-Origin"[..],
&b"*"[..],
).unwrap(),
);
let _ = request.respond(response);
}
ds_incremental::IncrementalResult::Error(e) => {
println!(" ❌ compile error");
let json = format!(r#"{{"type":"error","message":{}}}"#, json_escape(&e));
let response = tiny_http::Response::from_string(&json)
.with_status_code(400)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"application/json; charset=utf-8"[..],
).unwrap(),
);
let _ = request.respond(response);
}
}
} else {
// Serve the playground HTML
let response = tiny_http::Response::from_string(&playground_html)
.with_header(
tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
).unwrap(),
);
let _ = request.respond(response);
}
}
}
fn default_playground_source() -> String {
r#"let count = 0
let label = "Hello, DreamStack!"
on click -> count = count + 1
view main = column [
text label
text count
button "Increment" { click: count += 1 }
]
"#.to_string()
}
/// Escape a string for embedding in JSON.
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c < '\x20' => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
// ── Registry: dreamstack add ──
struct RegistryItem {
name: &'static str,
description: &'static str,
source: &'static str,
deps: &'static [&'static str],
}
const REGISTRY: &[RegistryItem] = &[
RegistryItem {
name: "button",
description: "Styled button with variant support (primary, secondary, ghost, destructive)",
source: include_str!("../../../registry/components/button.ds"),
deps: &[],
},
RegistryItem {
name: "input",
description: "Text input with label, placeholder, and error state",
source: include_str!("../../../registry/components/input.ds"),
deps: &[],
},
RegistryItem {
name: "card",
description: "Content container with title and styled border",
source: include_str!("../../../registry/components/card.ds"),
deps: &[],
},
RegistryItem {
name: "badge",
description: "Status badge with color variants",
source: include_str!("../../../registry/components/badge.ds"),
deps: &[],
},
RegistryItem {
name: "dialog",
description: "Modal dialog with overlay and close button",
source: include_str!("../../../registry/components/dialog.ds"),
deps: &["button"],
},
RegistryItem {
name: "toast",
description: "Notification toast with auto-dismiss",
source: include_str!("../../../registry/components/toast.ds"),
deps: &[],
},
RegistryItem {
name: "progress",
description: "Animated progress bar with percentage",
source: include_str!("../../../registry/components/progress.ds"),
deps: &[],
},
RegistryItem {
name: "alert",
description: "Alert banner with info/warning/error/success variants",
source: include_str!("../../../registry/components/alert.ds"),
deps: &[],
},
RegistryItem {
name: "separator",
description: "Visual divider between content sections",
source: include_str!("../../../registry/components/separator.ds"),
deps: &[],
},
RegistryItem {
name: "toggle",
description: "On/off switch toggle",
source: include_str!("../../../registry/components/toggle.ds"),
deps: &[],
},
RegistryItem {
name: "avatar",
description: "User avatar with initials fallback",
source: include_str!("../../../registry/components/avatar.ds"),
deps: &[],
},
];
fn cmd_add(name: Option<String>, list: bool, all: bool) {
if list {
println!("📦 Available DreamStack components:\n");
for item in REGISTRY {
let deps = if item.deps.is_empty() {
String::new()
} else {
format!(" (deps: {})", item.deps.join(", "))
};
println!(" {}{}{}", item.name, item.description, deps);
}
println!("\n Use: dreamstack add <name>");
return;
}
let components_dir = Path::new("components");
if !components_dir.exists() {
fs::create_dir_all(components_dir).expect("Failed to create components/ directory");
}
let names_to_add: Vec<String> = if all {
REGISTRY.iter().map(|r| r.name.to_string()).collect()
} else if let Some(name) = name {
vec![name]
} else {
println!("Usage: dreamstack add <component>\n dreamstack add --list\n dreamstack add --all");
return;
};
let mut added = std::collections::HashSet::new();
for name in &names_to_add {
add_component(name, &components_dir, &mut added);
}
if added.is_empty() {
println!("❌ No components found. Use 'dreamstack add --list' to see available.");
}
}
fn add_component(name: &str, dest: &Path, added: &mut std::collections::HashSet<String>) {
if added.contains(name) {
return;
}
let item = match REGISTRY.iter().find(|r| r.name == name) {
Some(item) => item,
None => {
println!(" ❌ Unknown component: {name}");
return;
}
};
// Add dependencies first
for dep in item.deps {
add_component(dep, dest, added);
}
let dest_file = dest.join(format!("{}.ds", name));
// Fix imports: ./button → ./button (relative within components/)
let source = item.source.replace("from \"./", "from \"./");
fs::write(&dest_file, source).expect("Failed to write component file");
let dep_info = if !item.deps.is_empty() {
" (dependency)"
} else {
""
};
println!(" ✅ Added components/{}.ds{}", name, dep_info);
added.insert(name.to_string());
}
// ── Converter: dreamstack convert ──
fn cmd_convert(name: &str, shadcn: bool, output: Option<&Path>) {
let tsx_source = if shadcn {
// Fetch from shadcn/ui GitHub
let url = format!(
"https://raw.githubusercontent.com/shadcn-ui/taxonomy/main/components/ui/{}.tsx",
name
);
println!(" 📥 Fetching {}.tsx from shadcn/ui...", name);
match fetch_url_blocking(&url) {
Ok(source) => source,
Err(e) => {
println!(" ❌ Failed to fetch: {e}");
println!(" Try providing a local .tsx file instead.");
return;
}
}
} else {
// Read local file
match fs::read_to_string(name) {
Ok(source) => source,
Err(e) => {
println!(" ❌ Cannot read '{name}': {e}");
return;
}
}
};
let ds_output = convert_tsx_to_ds(&tsx_source, name);
if let Some(out_path) = output {
fs::write(out_path, &ds_output).expect("Failed to write output file");
println!(" ✅ Converted to {}", out_path.display());
} else {
println!("{}", ds_output);
}
}
/// Best-effort TSX → DreamStack converter.
/// Pattern-matches common React/shadcn idioms rather than full TypeScript parsing.
fn convert_tsx_to_ds(tsx: &str, file_hint: &str) -> String {
let mut out = String::new();
// Extract component name
let comp_name = extract_component_name(tsx)
.unwrap_or_else(|| {
// Derive from filename
let base = Path::new(file_hint)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Component");
let mut chars = base.chars();
match chars.next() {
Some(c) => format!("{}{}", c.to_uppercase().collect::<String>(), chars.collect::<String>()),
None => "Component".to_string(),
}
});
// Extract props
let props = extract_props(tsx);
// Header comment
out.push_str(&format!("-- Converted from {}\n", file_hint));
out.push_str("-- Auto-generated by dreamstack convert\n\n");
// Extract useState hooks → let declarations
let state_vars = extract_use_state(tsx);
for (name, default) in &state_vars {
out.push_str(&format!("let {} = {}\n", name, default));
}
if !state_vars.is_empty() {
out.push('\n');
}
// Extract cva variants
let variants = extract_cva_variants(tsx);
if !variants.is_empty() {
out.push_str("-- Variants:\n");
for (variant_name, values) in &variants {
out.push_str(&format!("-- {}: {}\n", variant_name, values.join(", ")));
}
out.push('\n');
}
// Extract JSX body
let jsx_body = extract_jsx_body(tsx);
// Build component declaration
let props_str = if props.is_empty() {
String::new()
} else {
format!("({})", props.join(", "))
};
out.push_str(&format!("export component {}{} =\n", comp_name, props_str));
if jsx_body.is_empty() {
out.push_str(" text \"TODO: convert JSX body\"\n");
} else {
out.push_str(&jsx_body);
}
out
}
/// Extract component name from React.forwardRef or function/const declaration
fn extract_component_name(tsx: &str) -> Option<String> {
// Pattern: `const Button = React.forwardRef`
for line in tsx.lines() {
let trimmed = line.trim();
if trimmed.contains("forwardRef") || trimmed.contains("React.forwardRef") {
if let Some(pos) = trimmed.find("const ") {
let rest = &trimmed[pos + 6..];
if let Some(eq_pos) = rest.find(" =") {
return Some(rest[..eq_pos].trim().to_string());
}
}
}
// Pattern: `export function Button(`
if trimmed.starts_with("export function ") || trimmed.starts_with("function ") {
let rest = trimmed.strip_prefix("export ").unwrap_or(trimmed);
let rest = rest.strip_prefix("function ").unwrap_or(rest);
if let Some(paren) = rest.find('(') {
let name = rest[..paren].trim();
if name.chars().next().map_or(false, |c| c.is_uppercase()) {
return Some(name.to_string());
}
}
}
}
None
}
/// Extract props from destructured function parameters
fn extract_props(tsx: &str) -> Vec<String> {
let mut props = Vec::new();
// Look for destructured props: `({ className, variant, size, ...props })`
if let Some(start) = tsx.find("({ ") {
if let Some(end) = tsx[start..].find(" })") {
let inner = &tsx[start + 3..start + end];
for part in inner.split(',') {
let part = part.trim();
if part.starts_with("...") { continue; } // skip rest props
if part.is_empty() { continue; }
// Handle defaults: `variant = "default"` → just `variant`
let name = part.split('=').next().unwrap_or(part).trim();
let name = name.split(':').next().unwrap_or(name).trim();
if !name.is_empty()
&& name != "className"
&& name != "ref"
&& name != "children"
&& !props.contains(&name.to_string())
{
props.push(name.to_string());
}
}
}
}
props
}
/// Extract useState hooks: `const [open, setOpen] = useState(false)` → ("open", "false")
fn extract_use_state(tsx: &str) -> Vec<(String, String)> {
let mut states = Vec::new();
for line in tsx.lines() {
let trimmed = line.trim();
if trimmed.contains("useState") {
// const [name, setName] = useState(default)
if let Some(bracket_start) = trimmed.find('[') {
if let Some(comma) = trimmed[bracket_start..].find(',') {
let name = trimmed[bracket_start + 1..bracket_start + comma].trim();
// Extract default value from useState(...)
if let Some(paren_start) = trimmed.find("useState(") {
let rest = &trimmed[paren_start + 9..];
if let Some(paren_end) = rest.find(')') {
let default = rest[..paren_end].trim();
let ds_default = convert_value(default);
states.push((name.to_string(), ds_default));
}
}
}
}
}
}
states
}
/// Extract cva variant definitions
fn extract_cva_variants(tsx: &str) -> Vec<(String, Vec<String>)> {
let mut variants = Vec::new();
let mut in_variants = false;
let mut current_variant = String::new();
let mut current_values = Vec::new();
for line in tsx.lines() {
let trimmed = line.trim();
if trimmed == "variants: {" {
in_variants = true;
continue;
}
if in_variants {
if trimmed == "}," || trimmed == "}" {
if !current_variant.is_empty() {
variants.push((current_variant.clone(), current_values.clone()));
current_variant.clear();
current_values.clear();
}
if trimmed == "}," {
// Could be end of a variant group or end of variants
if trimmed == "}," { continue; }
}
in_variants = false;
continue;
}
// Variant group: `variant: {`
if trimmed.ends_with(": {") || trimmed.ends_with(":{") {
if !current_variant.is_empty() {
variants.push((current_variant.clone(), current_values.clone()));
current_values.clear();
}
current_variant = trimmed.split(':').next().unwrap_or("").trim().to_string();
continue;
}
// Variant value: `default: "bg-primary text-primary-foreground",`
if trimmed.contains(":") && !current_variant.is_empty() {
let name = trimmed.split(':').next().unwrap_or("").trim().trim_matches('"');
if !name.is_empty() {
current_values.push(name.to_string());
}
}
}
}
if !current_variant.is_empty() {
variants.push((current_variant, current_values));
}
variants
}
/// Convert a JSX body to DreamStack view syntax (best-effort)
fn extract_jsx_body(tsx: &str) -> String {
let mut out = String::new();
let mut in_return = false;
let mut depth = 0;
for line in tsx.lines() {
let trimmed = line.trim();
// Find the return statement
if trimmed.starts_with("return (") || trimmed == "return (" {
in_return = true;
depth = 1;
continue;
}
if !in_return { continue; }
// Track parens
for c in trimmed.chars() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth <= 0 {
in_return = false;
}
}
_ => {}
}
}
if !in_return && depth <= 0 { break; }
// Convert JSX elements
let converted = convert_jsx_line(trimmed);
if !converted.is_empty() {
out.push_str(" ");
out.push_str(&converted);
out.push('\n');
}
}
out
}
/// Convert a single JSX line to DreamStack syntax
fn convert_jsx_line(jsx: &str) -> String {
let trimmed = jsx.trim();
// Skip closing tags
if trimmed.starts_with("</") { return String::new(); }
// Skip fragments
if trimmed == "<>" || trimmed == "</>" { return String::new(); }
// Skip className-only attributes
if trimmed.starts_with("className=") { return String::new(); }
// Self-closing tag: `<Component prop="val" />`
if trimmed.starts_with('<') && trimmed.ends_with("/>") {
let inner = &trimmed[1..trimmed.len() - 2].trim();
let parts: Vec<&str> = inner.splitn(2, ' ').collect();
let tag = parts[0];
let ds_tag = convert_html_tag(tag);
if parts.len() > 1 {
let attrs = convert_jsx_attrs(parts[1]);
return format!("{} {{ {} }}", ds_tag, attrs);
}
return ds_tag;
}
// Opening tag: `<button ... >`
if trimmed.starts_with('<') && !trimmed.starts_with("</") {
let close = trimmed.find('>').unwrap_or(trimmed.len());
let inner = &trimmed[1..close].trim();
let parts: Vec<&str> = inner.splitn(2, ' ').collect();
let tag = parts[0];
let ds_tag = convert_html_tag(tag);
// Check for text content after >
if close < trimmed.len() - 1 {
let content = trimmed[close + 1..].trim();
let content = content.trim_end_matches(&format!("</{}>", tag));
if !content.is_empty() {
return format!("{} \"{}\"", ds_tag, content);
}
}
if parts.len() > 1 {
let attrs = convert_jsx_attrs(parts[1]);
return format!("{} {{ {} }}", ds_tag, attrs);
}
return ds_tag;
}
// JSX expression: `{children}`, `{title}`
if trimmed.starts_with('{') && trimmed.ends_with('}') {
let expr = &trimmed[1..trimmed.len() - 1].trim();
// Conditional: `{condition && <X/>}`
if expr.contains(" && ") {
let parts: Vec<&str> = expr.splitn(2, " && ").collect();
return format!("-- when {} -> ...", parts[0]);
}
return format!("text {}", expr);
}
// Plain text content
if !trimmed.is_empty() && !trimmed.starts_with("//") && !trimmed.starts_with("/*") {
return format!("-- {}", trimmed);
}
String::new()
}
/// Convert HTML tag to DreamStack element
fn convert_html_tag(tag: &str) -> String {
match tag {
"button" => "button".to_string(),
"input" => "input".to_string(),
"div" => "column [".to_string(),
"span" => "text".to_string(),
"p" => "text".to_string(),
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "text".to_string(),
"img" => "image".to_string(),
"a" => "link".to_string(),
"label" => "label".to_string(),
"form" => "column [".to_string(),
"ul" | "ol" => "column [".to_string(),
"li" => "text".to_string(),
// Capitalized = component use
_ if tag.chars().next().map_or(false, |c| c.is_uppercase()) => {
format!("{}", tag)
}
_ => format!("-- unknown: <{}>", tag),
}
}
/// Convert JSX attributes to DreamStack props
fn convert_jsx_attrs(attrs: &str) -> String {
let mut props = Vec::new();
// Simple: key="value" or key={expr}
let mut remaining = attrs.trim().trim_end_matches('>').trim_end_matches('/').trim();
while !remaining.is_empty() {
// Skip className
if remaining.starts_with("className=") {
// Skip to next attr
if let Some(quote_end) = skip_attr_value(remaining) {
remaining = remaining[quote_end..].trim();
continue;
}
break;
}
// Skip ref
if remaining.starts_with("ref=") {
if let Some(quote_end) = skip_attr_value(remaining) {
remaining = remaining[quote_end..].trim();
continue;
}
break;
}
// Skip {...props}
if remaining.starts_with("{...") {
if let Some(end) = remaining.find('}') {
remaining = remaining[end + 1..].trim();
continue;
}
break;
}
// Parse key=value
if let Some(eq_pos) = remaining.find('=') {
let key = remaining[..eq_pos].trim();
let rest = remaining[eq_pos + 1..].trim();
let ds_key = convert_event_name(key);
if rest.starts_with('"') {
// String value
if let Some(end) = rest[1..].find('"') {
let val = &rest[1..1 + end];
props.push(format!("{}: \"{}\"", ds_key, val));
remaining = rest[end + 2..].trim();
} else {
break;
}
} else if rest.starts_with('{') {
// Expression value
if let Some(end) = find_matching_brace(rest) {
let expr = &rest[1..end].trim();
props.push(format!("{}: {}", ds_key, expr));
remaining = rest[end + 1..].trim();
} else {
break;
}
} else {
break;
}
} else {
break;
}
}
props.join(", ")
}
fn convert_event_name(name: &str) -> String {
match name {
"onClick" => "click".to_string(),
"onChange" => "change".to_string(),
"onSubmit" => "submit".to_string(),
"onKeyDown" => "keydown".to_string(),
"onFocus" => "focus".to_string(),
"onBlur" => "blur".to_string(),
"disabled" => "disabled".to_string(),
"placeholder" => "placeholder".to_string(),
"type" => "type".to_string(),
"value" => "value".to_string(),
"href" => "href".to_string(),
"src" => "src".to_string(),
"alt" => "alt".to_string(),
_ => name.to_string(),
}
}
fn convert_value(val: &str) -> String {
match val {
"true" => "true".to_string(),
"false" => "false".to_string(),
"null" | "undefined" => "0".to_string(),
s if s.starts_with('"') => s.to_string(),
s if s.starts_with('\'') => format!("\"{}\"", &s[1..s.len()-1]),
s => s.to_string(),
}
}
fn skip_attr_value(s: &str) -> Option<usize> {
let eq = s.find('=')?;
let rest = &s[eq + 1..];
if rest.starts_with('"') {
let end = rest[1..].find('"')?;
Some(eq + 1 + end + 2)
} else if rest.starts_with('{') {
let end = find_matching_brace(rest)?;
Some(eq + 1 + end + 1)
} else {
Some(eq + 1)
}
}
fn find_matching_brace(s: &str) -> Option<usize> {
let mut depth = 0;
for (i, c) in s.chars().enumerate() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 { return Some(i); }
}
_ => {}
}
}
None
}
/// Simple blocking HTTP fetch (no async runtime needed)
fn fetch_url_blocking(url: &str) -> Result<String, String> {
// Use std::process to call curl
let output = std::process::Command::new("curl")
.args(["-sL", "--fail", url])
.output()
.map_err(|e| format!("Failed to run curl: {e}"))?;
if !output.status.success() {
return Err(format!("HTTP request failed (status {})", output.status));
}
String::from_utf8(output.stdout)
.map_err(|e| format!("Invalid UTF-8 in response: {e}"))
}