diff --git a/Cargo.toml b/Cargo.toml index b0a2d77..f4e7f6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,12 @@ members = [ "compiler/ds-codegen", "compiler/ds-layout", "compiler/ds-types", + "compiler/ds-incremental", "compiler/ds-cli", "engine/ds-physics", "engine/ds-stream", "engine/ds-stream-wasm", + "bench", ] [workspace.package] @@ -23,6 +25,7 @@ ds-analyzer = { path = "compiler/ds-analyzer" } ds-codegen = { path = "compiler/ds-codegen" } ds-layout = { path = "compiler/ds-layout" } ds-types = { path = "compiler/ds-types" } +ds-incremental = { path = "compiler/ds-incremental" } ds-physics = { path = "engine/ds-physics" } ds-stream = { path = "engine/ds-stream" } ds-stream-wasm = { path = "engine/ds-stream-wasm" } diff --git a/bench/Cargo.toml b/bench/Cargo.toml new file mode 100644 index 0000000..1c4d3fb --- /dev/null +++ b/bench/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ds-bench" +version.workspace = true +edition.workspace = true + +[[bench]] +name = "compiler_bench" +harness = false + +[dependencies] +ds-parser = { workspace = true } +ds-analyzer = { workspace = true } +ds-codegen = { workspace = true } +ds-layout = { workspace = true } + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } diff --git a/bench/benches/compiler_bench.rs b/bench/benches/compiler_bench.rs new file mode 100644 index 0000000..55efa38 --- /dev/null +++ b/bench/benches/compiler_bench.rs @@ -0,0 +1,181 @@ +/// DreamStack Compiler & Layout Benchmarks +/// +/// Measures: +/// - Compiler pipeline throughput (parse → analyze → emit) +/// - Signal graph construction at various scales +/// - Cassowary constraint solver with varying constraint counts + +use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId}; + +// ── Compiler pipeline benchmarks ──────────────────────────── + +fn counter_source() -> &'static str { + r#" +let count = 0 +on click { count = count + 1 } +view main = column [ + text { "Count: " + count } + button("Increment") { on: click } +] +"# +} + +fn todo_source() -> &'static str { + r#" +let todos = [] +let input = "" +let filter = "all" + +on addTodo { + todos = todos + [{ text: input, done: false }] + input = "" +} + +on toggleTodo { + todos = todos +} + +view main = column [ + row [ + text { input } + button("Add") { on: addTodo } + ] + column [ + for todo in todos { + row [ + text { todo.text } + ] + } + ] + row [ + button("All") { on: toggleTodo } + button("Active") { on: toggleTodo } + button("Done") { on: toggleTodo } + ] +] +"# +} + +/// Generate a stress-test source with N signals and derived values. +fn signal_chain_source(n: usize) -> String { + let mut src = String::new(); + src.push_str("let s0 = 0\n"); + for i in 1..n { + src.push_str(&format!("let s{i} = s{} + 1\n", i - 1)); + } + src.push_str(&format!( + "view main = column [\n text {{ \"Last: \" + s{} }}\n]\n", + n - 1 + )); + src +} + +/// Generate a fan-out source: 1 root signal, N derived dependents. +fn fan_out_source(n: usize) -> String { + let mut src = String::from("let root = 0\n"); + for i in 0..n { + src.push_str(&format!("let d{i} = root + {i}\n")); + } + // View shows last derived + src.push_str(&format!( + "view main = column [\n text {{ \"d0: \" + d0 }}\n]\n", + )); + src +} + +fn compile_source(source: &str) -> String { + let program = ds_parser::parse(source).expect("parse failed"); + let (graph, views) = ds_analyzer::analyze(&program); + ds_codegen::JsEmitter::emit_html(&program, &graph, &views) +} + +fn bench_compiler_pipeline(c: &mut Criterion) { + let mut group = c.benchmark_group("compiler_pipeline"); + + group.bench_function("counter", |b| { + let src = counter_source(); + b.iter(|| compile_source(black_box(src))); + }); + + group.bench_function("todo", |b| { + let src = todo_source(); + b.iter(|| compile_source(black_box(src))); + }); + + group.finish(); +} + +fn bench_signal_chain(c: &mut Criterion) { + let mut group = c.benchmark_group("signal_chain"); + + for &n in &[10, 50, 100, 500, 1000] { + let src = signal_chain_source(n); + group.bench_with_input(BenchmarkId::from_parameter(n), &src, |b, src| { + b.iter(|| compile_source(black_box(src))); + }); + } + + group.finish(); +} + +fn bench_fan_out(c: &mut Criterion) { + let mut group = c.benchmark_group("fan_out"); + + for &n in &[10, 50, 100, 500, 1000] { + let src = fan_out_source(n); + group.bench_with_input(BenchmarkId::from_parameter(n), &src, |b, src| { + b.iter(|| compile_source(black_box(src))); + }); + } + + group.finish(); +} + +// ── Constraint solver benchmarks ──────────────────────────── + +fn bench_constraint_solver(c: &mut Criterion) { + use ds_layout::{LayoutSolver, Variable, Constraint, Strength}; + + let mut group = c.benchmark_group("constraint_solver"); + + for &n in &[10, 50, 100, 500] { + group.bench_with_input(BenchmarkId::new("panel_chain", n), &n, |b, &n| { + b.iter(|| { + let mut solver = LayoutSolver::new(); + // Create a chain of N panels: each starts where the previous ends + let mut vars = Vec::new(); + for _ in 0..n { + let x = Variable::new(); + let w = Variable::new(); + vars.push((x, w)); + } + // First panel at x=0, width=100 + solver.add_constraint(Constraint::eq_const(vars[0].0, 0.0, Strength::Required)); + solver.add_constraint(Constraint::eq_const(vars[0].1, 100.0, Strength::Required)); + // Each subsequent panel: x_i = x_{i-1} + w_{i-1} + for i in 1..n { + solver.add_constraint(Constraint::sum_eq( + vars[i - 1].0, + vars[i - 1].1, + 0.0, + Strength::Required, + )); + solver.add_constraint(Constraint::eq_const(vars[i].1, 100.0, Strength::Required)); + } + solver.solve(); + black_box(solver.get_value(vars[n - 1].0)); + }); + }); + } + + group.finish(); +} + +criterion_group!( + benches, + bench_compiler_pipeline, + bench_signal_chain, + bench_fan_out, + bench_constraint_solver, +); +criterion_main!(benches); diff --git a/bench/src/lib.rs b/bench/src/lib.rs new file mode 100644 index 0000000..a051497 --- /dev/null +++ b/bench/src/lib.rs @@ -0,0 +1 @@ +pub fn add(a: i32, b: i32) -> i32 { a + b } diff --git a/compiler/ds-cli/Cargo.toml b/compiler/ds-cli/Cargo.toml index 8311ba9..058d81f 100644 --- a/compiler/ds-cli/Cargo.toml +++ b/compiler/ds-cli/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" ds-parser = { workspace = true } ds-analyzer = { workspace = true } ds-codegen = { workspace = true } +ds-incremental = { workspace = true } clap = { version = "4", features = ["derive"] } notify = "8" tiny_http = "0.12" diff --git a/compiler/ds-cli/src/main.rs b/compiler/ds-cli/src/main.rs index 670c07f..661702d 100644 --- a/compiler/ds-cli/src/main.rs +++ b/compiler/ds-cli/src/main.rs @@ -56,6 +56,36 @@ enum Commands { #[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, + /// 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, + /// 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, + }, } fn main() { @@ -66,6 +96,9 @@ fn main() { 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()), } } @@ -576,3 +609,1188 @@ fn cmd_stream(file: &Path, relay: &str, mode: &str, port: u16) { } } } + +// ── Playground ────────────────────────────────────────────── + +/// The playground HTML page with Monaco editor + live preview. +const PLAYGROUND_HTML: &str = r##" + + + + +DreamStack Playground + + + + + +
+ + +
+ + Ready +
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+ + + + + +"##; + +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: &[], + }, +]; + +fn cmd_add(name: Option, 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 "); + 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 = if all { + REGISTRY.iter().map(|r| r.name.to_string()).collect() + } else if let Some(name) = name { + vec![name] + } else { + println!("Usage: dreamstack add \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) { + 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::(), chars.collect::()), + 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 { + // 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 { + 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)> { + 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("" || trimmed == "" { return String::new(); } + // Skip className-only attributes + if trimmed.starts_with("className=") { return String::new(); } + + // Self-closing tag: `` + 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: `