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
1826 lines
61 KiB
Rust
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}"))
|
|
}
|