feat: component registry with styled variants, dreamstack add/convert CLI, and showcase

- Phase 1: Component parser + codegen (emit_component_decl, emit_component_use, emit_match)
- Phase 2: 6 registry components (button, input, card, badge, dialog, toast)
- Phase 3: dreamstack add CLI with dependency resolution and --list/--all
- Phase 4: dreamstack convert TSX→DS transpiler with --shadcn GitHub fetch
- Phase 5: 120+ lines variant CSS (buttons, badges, cards, dialog, toast, input)
- New example: showcase.ds demonstrating all component styles
This commit is contained in:
enzotar 2026-02-26 13:27:49 -08:00
parent 61c26acfa7
commit 7805b94704
22 changed files with 2431 additions and 11 deletions

View file

@ -6,10 +6,12 @@ members = [
"compiler/ds-codegen", "compiler/ds-codegen",
"compiler/ds-layout", "compiler/ds-layout",
"compiler/ds-types", "compiler/ds-types",
"compiler/ds-incremental",
"compiler/ds-cli", "compiler/ds-cli",
"engine/ds-physics", "engine/ds-physics",
"engine/ds-stream", "engine/ds-stream",
"engine/ds-stream-wasm", "engine/ds-stream-wasm",
"bench",
] ]
[workspace.package] [workspace.package]
@ -23,6 +25,7 @@ ds-analyzer = { path = "compiler/ds-analyzer" }
ds-codegen = { path = "compiler/ds-codegen" } ds-codegen = { path = "compiler/ds-codegen" }
ds-layout = { path = "compiler/ds-layout" } ds-layout = { path = "compiler/ds-layout" }
ds-types = { path = "compiler/ds-types" } ds-types = { path = "compiler/ds-types" }
ds-incremental = { path = "compiler/ds-incremental" }
ds-physics = { path = "engine/ds-physics" } ds-physics = { path = "engine/ds-physics" }
ds-stream = { path = "engine/ds-stream" } ds-stream = { path = "engine/ds-stream" }
ds-stream-wasm = { path = "engine/ds-stream-wasm" } ds-stream-wasm = { path = "engine/ds-stream-wasm" }

17
bench/Cargo.toml Normal file
View file

@ -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"] }

View file

@ -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);

1
bench/src/lib.rs Normal file
View file

@ -0,0 +1 @@
pub fn add(a: i32, b: i32) -> i32 { a + b }

View file

@ -11,6 +11,7 @@ path = "src/main.rs"
ds-parser = { workspace = true } ds-parser = { workspace = true }
ds-analyzer = { workspace = true } ds-analyzer = { workspace = true }
ds-codegen = { workspace = true } ds-codegen = { workspace = true }
ds-incremental = { workspace = true }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
notify = "8" notify = "8"
tiny_http = "0.12" tiny_http = "0.12"

File diff suppressed because it is too large Load diff

View file

@ -210,7 +210,24 @@ impl JsEmitter {
} }
} }
// Phase 2b: Build views // Phase 2b: Component functions
let has_components = program.declarations.iter().any(|d| matches!(d, Declaration::Component(_)));
if has_components {
self.emit_line("// ── Components ──");
for decl in &program.declarations {
if let Declaration::Component(comp) = decl {
self.emit_component_decl(comp, graph);
}
// Also handle exported components
if let Declaration::Export(_, inner) = decl {
if let Declaration::Component(comp) = inner.as_ref() {
self.emit_component_decl(comp, graph);
}
}
}
}
// Phase 2c: Build views
self.emit_line("// ── Views ──"); self.emit_line("// ── Views ──");
for decl in &program.declarations { for decl in &program.declarations {
if let Declaration::View(view) = decl { if let Declaration::View(view) = decl {
@ -307,6 +324,76 @@ impl JsEmitter {
} }
} }
// Phase 6b: Layout blocks
let layouts: Vec<_> = program.declarations.iter()
.filter_map(|d| if let Declaration::Layout(l) = d { Some(l) } else { None })
.collect();
for layout in &layouts {
self.emit_line("");
self.emit_line(&format!("// ── Layout: {} ──", layout.name));
self.emit_line(&format!("(function setupLayout_{}() {{", layout.name));
self.indent += 1;
self.emit_line("function solve() {");
self.indent += 1;
self.emit_line("const vp = { width: window.innerWidth, height: window.innerHeight };");
// Collect all element names referenced
let mut elements = std::collections::HashSet::new();
for c in &layout.constraints {
Self::collect_layout_elements(&c.left, &mut elements);
Self::collect_layout_elements(&c.right, &mut elements);
}
// Create element variable objects: { x, y, width, height }
for el in &elements {
if el == "parent" {
self.emit_line(&format!(
"const {el} = {{ x: 0, y: 0, width: vp.width, height: vp.height }};"
));
} else {
self.emit_line(&format!(
"const {el} = {{ x: 0, y: 0, width: 0, height: 0 }};"
));
}
}
// Emit constraint assignments (simple direct assignment for == constraints)
for c in &layout.constraints {
let left_js = Self::layout_expr_to_js(&c.left);
let right_js = Self::layout_expr_to_js(&c.right);
match c.op {
ds_parser::ConstraintOp::Eq => {
self.emit_line(&format!("{left_js} = {right_js};"));
}
ds_parser::ConstraintOp::Gte => {
self.emit_line(&format!("{left_js} = Math.max({left_js}, {right_js});"));
}
ds_parser::ConstraintOp::Lte => {
self.emit_line(&format!("{left_js} = Math.min({left_js}, {right_js});"));
}
}
}
// Apply solved values to DOM elements
for el in &elements {
if el == "parent" { continue; }
self.emit_line(&format!(
"const {el}El = document.querySelector('[data-ds=\"{el}\"]');"
));
self.emit_line(&format!(
"if ({el}El) {{ {el}El.style.position = 'absolute'; {el}El.style.left = {el}.x + 'px'; {el}El.style.top = {el}.y + 'px'; {el}El.style.width = {el}.width + 'px'; {el}El.style.height = {el}.height + 'px'; }}"
));
}
self.indent -= 1;
self.emit_line("}");
self.emit_line("solve();");
self.emit_line("window.addEventListener('resize', solve);");
self.indent -= 1;
self.emit_line("})();");
}
// Phase 7: Stream initialization // Phase 7: Stream initialization
let streams: Vec<_> = program.declarations.iter() let streams: Vec<_> = program.declarations.iter()
.filter_map(|d| if let Declaration::Stream(s) = d { Some(s) } else { None }) .filter_map(|d| if let Declaration::Stream(s) = d { Some(s) } else { None })
@ -376,6 +463,19 @@ impl JsEmitter {
self.emit_line("}"); self.emit_line("}");
} }
fn emit_component_decl(&mut self, comp: &ComponentDecl, graph: &SignalGraph) {
self.emit_line(&format!("function DS_{}(props) {{", comp.name));
self.indent += 1;
// Destructure props into local variables
for p in &comp.props {
self.emit_line(&format!("const {} = props.{} !== undefined ? props.{} : null;", p.name, p.name, p.name));
}
let root_id = self.emit_view_expr(&comp.body, graph);
self.emit_line(&format!("return {};", root_id));
self.indent -= 1;
self.emit_line("}");
}
/// Emit a view expression and return the variable name of the created DOM node. /// Emit a view expression and return the variable name of the created DOM node.
fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String { fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String {
match expr { match expr {
@ -640,14 +740,13 @@ impl JsEmitter {
container_var container_var
} }
// Component usage: `<Card title="hello" />` // Component usage: `Button { label: "hello" }`
Expr::ComponentUse { name, props, children } => { Expr::ComponentUse { name, props, children: _ } => {
let args = props.iter()
.map(|(k, v)| self.emit_expr(v))
.collect::<Vec<_>>()
.join(", ");
let node_var = self.next_node_id(); let node_var = self.next_node_id();
self.emit_line(&format!("const {} = component_{}({});", node_var, name, args)); let props_js: Vec<String> = props.iter()
.map(|(k, v)| format!("{}: {}", k, self.emit_expr(v)))
.collect();
self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", ")));
node_var node_var
} }
@ -678,6 +777,7 @@ impl JsEmitter {
container_var container_var
} }
// Fallback: just create a text node // Fallback: just create a text node
_ => { _ => {
let node_var = self.next_node_id(); let node_var = self.next_node_id();
@ -708,7 +808,7 @@ impl JsEmitter {
// ── Expression emitters ───────────────────────────── // ── Expression emitters ─────────────────────────────
fn emit_expr(&self, expr: &Expr) -> String { pub fn emit_expr(&self, expr: &Expr) -> String {
match expr { match expr {
Expr::IntLit(n) => format!("{n}"), Expr::IntLit(n) => format!("{n}"),
Expr::FloatLit(n) => format!("{n}"), Expr::FloatLit(n) => format!("{n}"),
@ -897,6 +997,43 @@ impl JsEmitter {
format!("DS._connectStream(\"{}\", [{}])", source, select_js.join(",")) format!("DS._connectStream(\"{}\", [{}])", source, select_js.join(","))
} }
} }
Expr::Match(scrutinee, arms) => {
let scrut_js = self.emit_expr(scrutinee);
if arms.is_empty() {
return "null".to_string();
}
// Build chained ternary: (s === "a" ? exprA : s === "b" ? exprB : defaultExpr)
let mut parts = Vec::new();
let mut default_js = "null".to_string();
for arm in arms {
let body_js = self.emit_expr(&arm.body);
match &arm.pattern {
Pattern::Wildcard | Pattern::Ident(_) => {
default_js = body_js;
}
Pattern::Literal(lit_expr) => {
let lit_js = self.emit_expr(lit_expr);
parts.push(format!("({scrut_js} === {lit_js} ? {body_js}"));
}
Pattern::Constructor(name, _) => {
parts.push(format!("({scrut_js} === \"{name}\" ? {body_js}"));
}
}
}
if parts.is_empty() {
default_js
} else {
let close_parens = ")".repeat(parts.len());
format!("{} : {}{}", parts.join(" : "), default_js, close_parens)
}
}
Expr::ComponentUse { name, props, children: _ } => {
let props_js: Vec<String> = props
.iter()
.map(|(k, v)| format!("{}: {}", k, self.emit_expr(v)))
.collect();
format!("DS_{}({{ {} }})", name, props_js.join(", "))
}
_ => "null".to_string(), _ => "null".to_string(),
} }
} }
@ -1094,6 +1231,35 @@ impl JsEmitter {
&& expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && expr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
} }
/// Collect all element names referenced in a layout expression.
fn collect_layout_elements(expr: &LayoutExpr, out: &mut std::collections::HashSet<String>) {
match expr {
LayoutExpr::Prop(el, _) => { out.insert(el.clone()); }
LayoutExpr::Add(a, b) | LayoutExpr::Sub(a, b) | LayoutExpr::Mul(a, b) => {
Self::collect_layout_elements(a, out);
Self::collect_layout_elements(b, out);
}
LayoutExpr::Const(_) => {}
}
}
/// Convert a layout expression to a JavaScript expression string.
fn layout_expr_to_js(expr: &LayoutExpr) -> String {
match expr {
LayoutExpr::Prop(el, prop) => format!("{el}.{prop}"),
LayoutExpr::Const(v) => {
if *v == (*v as i64) as f64 {
format!("{}", *v as i64)
} else {
format!("{v}")
}
}
LayoutExpr::Add(a, b) => format!("({} + {})", Self::layout_expr_to_js(a), Self::layout_expr_to_js(b)),
LayoutExpr::Sub(a, b) => format!("({} - {})", Self::layout_expr_to_js(a), Self::layout_expr_to_js(b)),
LayoutExpr::Mul(a, b) => format!("({} * {})", Self::layout_expr_to_js(a), Self::layout_expr_to_js(b)),
}
}
/// Emit a physics scene — canvas + WASM physics engine + animation loop /// Emit a physics scene — canvas + WASM physics engine + animation loop
fn emit_scene(&mut self, container: &Container, graph: &SignalGraph) -> String { fn emit_scene(&mut self, container: &Container, graph: &SignalGraph) -> String {
let wrapper_var = self.next_node_id(); let wrapper_var = self.next_node_id();
@ -1454,6 +1620,117 @@ body {
.ds-button:active { .ds-button:active {
transform: translateY(0); transform: translateY(0);
} }
/* ── Button Variants ── */
.ds-btn-primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
}
.ds-btn-primary:hover { box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4); }
.ds-btn-secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #e2e8f0;
box-shadow: none;
}
.ds-btn-secondary:hover { background: rgba(255, 255, 255, 0.12); }
.ds-btn-ghost {
background: transparent;
box-shadow: none;
color: #a5b4fc;
}
.ds-btn-ghost:hover { background: rgba(99, 102, 241, 0.1); }
.ds-btn-destructive {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3);
}
.ds-btn-destructive:hover { box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); }
/* ── Card ── */
.ds-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
padding: 1.5rem;
backdrop-filter: blur(12px);
transition: border-color 0.2s, box-shadow 0.2s;
}
.ds-card:hover {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
.ds-card-title {
font-size: 1.25rem;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 0.25rem;
}
.ds-card-subtitle {
font-size: 0.875rem;
color: #94a3b8;
}
/* ── Badge ── */
.ds-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.025em;
text-transform: uppercase;
}
.ds-badge-success { background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.2); }
.ds-badge-warning { background: rgba(234, 179, 8, 0.15); color: #facc15; border: 1px solid rgba(234, 179, 8, 0.2); }
.ds-badge-error { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.2); }
.ds-badge-info { background: rgba(56, 189, 248, 0.15); color: #38bdf8; border: 1px solid rgba(56, 189, 248, 0.2); }
.ds-badge-default { background: rgba(148, 163, 184, 0.15); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.2); }
/* ── Dialog ── */
.ds-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: ds-fade-in 0.2s ease-out;
}
.ds-dialog-content {
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 2rem;
min-width: 400px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: ds-fade-in 0.3s ease-out;
}
.ds-dialog-title {
font-size: 1.375rem;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 1rem;
}
/* ── Toast ── */
.ds-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: #1e1e3a;
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 12px;
padding: 1rem 1.5rem;
color: #e2e8f0;
font-size: 0.875rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
animation: ds-slide-in 0.3s ease-out;
z-index: 200;
}
@keyframes ds-slide-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Input Label ── */
.ds-input, input.ds-input { .ds-input, input.ds-input {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@ -1463,6 +1740,22 @@ body {
font-size: 1rem; font-size: 1rem;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
width: 100%;
box-sizing: border-box;
}
.ds-input-label {
font-size: 0.875rem;
font-weight: 500;
color: #94a3b8;
margin-bottom: 0.375rem;
}
.ds-input-error {
font-size: 0.75rem;
color: #f87171;
margin-top: 0.25rem;
}
.ds-input.ds-input-has-error {
border-color: rgba(239, 68, 68, 0.5);
} }
.ds-input:focus { .ds-input:focus {
border-color: #6366f1; border-color: #6366f1;

View file

@ -0,0 +1,9 @@
[package]
name = "ds-incremental"
version.workspace = true
edition.workspace = true
[dependencies]
ds-parser = { workspace = true }
ds-analyzer = { workspace = true }
ds-codegen = { workspace = true }

View file

@ -0,0 +1,283 @@
/// DreamStack Incremental Compiler
///
/// Diffs successive compilations to emit minimal JS patches when only
/// signal values change. Falls back to full recompile when structural
/// changes (new/removed declarations, view changes) are detected.
use ds_parser::{Program, Declaration, LetDecl, Lexer, Parser, TokenKind};
use ds_analyzer::SignalGraph;
use ds_codegen::JsEmitter;
/// Result of an incremental compilation.
pub enum IncrementalResult {
/// Full HTML — either first compile or structural change detected.
Full(String),
/// JS patch — only signal value updates, can be eval'd in-place.
Patch(String),
/// Compile error.
Error(String),
}
/// Delta between two programs.
#[derive(Debug)]
pub struct ProgramDelta {
/// Signals whose initial value expressions changed.
pub changed_signals: Vec<ChangedSignal>,
/// Whether structural changes require a full recompile.
pub needs_full_recompile: bool,
}
/// A signal with a changed initial value.
#[derive(Debug)]
pub struct ChangedSignal {
pub name: String,
pub new_expr_js: String,
}
/// Stateful incremental compiler. Holds the previous program AST
/// to diff against.
pub struct IncrementalCompiler {
prev_source: Option<String>,
prev_program: Option<Program>,
}
impl IncrementalCompiler {
pub fn new() -> Self {
IncrementalCompiler {
prev_source: None,
prev_program: None,
}
}
/// Parse source into a Program, returning an error string on failure.
fn parse_source(source: &str) -> Result<Program, String> {
let mut lexer = Lexer::new(source);
let tokens = lexer.tokenize();
// Check for lexer errors
for tok in &tokens {
if let TokenKind::Error(msg) = &tok.kind {
return Err(format!("Lexer error at line {}: {}", tok.line, msg));
}
}
let mut parser = Parser::new(tokens);
parser.parse_program().map_err(|e| e.to_string())
}
/// Full compilation pipeline.
fn full_compile(program: &Program) -> String {
let graph = SignalGraph::from_program(program);
let views = SignalGraph::analyze_views(program);
JsEmitter::emit_html(program, &graph, &views)
}
/// Compile source code, returning either a full HTML recompile or a JS patch.
pub fn compile(&mut self, source: &str) -> IncrementalResult {
// Fast path: source unchanged
if self.prev_source.as_deref() == Some(source) {
return IncrementalResult::Patch(String::new());
}
// Parse
let program = match Self::parse_source(source) {
Ok(p) => p,
Err(e) => return IncrementalResult::Error(e),
};
// Check if we can do incremental
let result = if let Some(prev) = &self.prev_program {
let delta = Self::diff_programs(prev, &program);
if delta.needs_full_recompile {
IncrementalResult::Full(Self::full_compile(&program))
} else if delta.changed_signals.is_empty() {
// No changes at all — return empty patch
IncrementalResult::Patch(String::new())
} else {
// Only signal values changed → emit patch
IncrementalResult::Patch(Self::emit_patch(&delta))
}
} else {
// First compile — always full
IncrementalResult::Full(Self::full_compile(&program))
};
// Save state
self.prev_source = Some(source.to_string());
self.prev_program = Some(program);
result
}
/// Diff two programs. Detects signal value changes and structural changes.
fn diff_programs(old: &Program, new: &Program) -> ProgramDelta {
// If declaration count differs, structural change
if old.declarations.len() != new.declarations.len() {
return ProgramDelta {
changed_signals: vec![],
needs_full_recompile: true,
};
}
// Check if declaration types match (structure preserved)
for (o, n) in old.declarations.iter().zip(new.declarations.iter()) {
if std::mem::discriminant(o) != std::mem::discriminant(n) {
return ProgramDelta {
changed_signals: vec![],
needs_full_recompile: true,
};
}
}
let old_lets = Self::extract_lets(old);
let new_lets = Self::extract_lets(new);
// Compare let declarations for value changes
let mut changed = Vec::new();
for (name, old_expr_dbg) in &old_lets {
// Find matching new let
if let Some((_, new_expr_dbg)) = new_lets.iter().find(|(n, _)| n == name) {
if old_expr_dbg != new_expr_dbg {
// Signal value changed — find the new expression and emit JS
for decl in &new.declarations {
if let Declaration::Let(LetDecl { name: n, value, .. }) = decl {
if n == name {
let emitter = JsEmitter::default();
let expr_js = emitter.emit_expr(value);
changed.push(ChangedSignal {
name: name.clone(),
new_expr_js: expr_js,
});
break;
}
}
}
}
} else {
// Signal removed → full recompile
return ProgramDelta {
changed_signals: vec![],
needs_full_recompile: true,
};
}
}
// Check for new signals
for (name, _) in &new_lets {
if !old_lets.iter().any(|(n, _)| n == name) {
return ProgramDelta {
changed_signals: vec![],
needs_full_recompile: true,
};
}
}
// Check if any view declarations changed (using Debug repr as cheap diff)
let old_views: Vec<String> = old.declarations.iter()
.filter_map(|d| if let Declaration::View(v) = d { Some(format!("{:?}", v)) } else { None })
.collect();
let new_views: Vec<String> = new.declarations.iter()
.filter_map(|d| if let Declaration::View(v) = d { Some(format!("{:?}", v)) } else { None })
.collect();
let needs_full = old_views != new_views;
ProgramDelta {
changed_signals: changed,
needs_full_recompile: needs_full,
}
}
/// Extract let declarations as (name, debug_string) pairs for comparison.
fn extract_lets(program: &Program) -> Vec<(String, String)> {
program.declarations.iter()
.filter_map(|d| {
if let Declaration::Let(LetDecl { name, value, .. }) = d {
Some((name.clone(), format!("{:?}", value)))
} else {
None
}
})
.collect()
}
/// Emit a JS patch that updates signal values without full page reload.
fn emit_patch(delta: &ProgramDelta) -> String {
let mut js = String::from("// DreamStack incremental patch\n");
for sig in &delta.changed_signals {
js.push_str(&format!(
"if (typeof DS !== 'undefined' && DS.signals && DS.signals.{name}) {{ DS.signals.{name}.value = {expr}; }}\n",
name = sig.name,
expr = sig.new_expr_js,
));
}
js
}
}
impl Default for IncrementalCompiler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_first_compile_is_full() {
let mut compiler = IncrementalCompiler::new();
let result = compiler.compile("let x = 42\nview main = column [\n text x\n]");
assert!(matches!(result, IncrementalResult::Full(_)));
}
#[test]
fn test_same_source_is_empty_patch() {
let mut compiler = IncrementalCompiler::new();
let src = "let x = 42\nview main = column [\n text x\n]";
compiler.compile(src);
let result = compiler.compile(src);
match result {
IncrementalResult::Patch(js) => assert!(js.is_empty()),
_ => panic!("expected empty patch for identical source"),
}
}
#[test]
fn test_signal_value_change_is_patch() {
let mut compiler = IncrementalCompiler::new();
let src1 = "let x = 42\nview main = column [\n text x\n]";
let src2 = "let x = 99\nview main = column [\n text x\n]";
compiler.compile(src1);
let result = compiler.compile(src2);
match result {
IncrementalResult::Patch(js) => {
assert!(js.contains("DS.signals.x"));
assert!(js.contains("99"));
}
_ => panic!("expected patch for signal value change"),
}
}
#[test]
fn test_structural_change_is_full() {
let mut compiler = IncrementalCompiler::new();
let src1 = "let x = 42\nview main = column [\n text x\n]";
let src2 = "let x = 42\nlet y = 10\nview main = column [\n text x\n]";
compiler.compile(src1);
let result = compiler.compile(src2);
assert!(matches!(result, IncrementalResult::Full(_)));
}
#[test]
fn test_view_change_triggers_full() {
let mut compiler = IncrementalCompiler::new();
let src1 = "let x = 42\nview main = column [\n text x\n]";
let src2 = "let x = 42\nview main = row [\n text x\n]";
compiler.compile(src1);
let result = compiler.compile(src2);
assert!(matches!(result, IncrementalResult::Full(_)));
}
}

View file

@ -36,6 +36,61 @@ pub enum Declaration {
Export(String, Box<Declaration>), Export(String, Box<Declaration>),
/// `type PositiveInt = Int where value > 0` /// `type PositiveInt = Int where value > 0`
TypeAlias(TypeAliasDecl), TypeAlias(TypeAliasDecl),
/// `layout dashboard { sidebar.width == 250 }`
Layout(LayoutDecl),
}
/// `layout name { constraints }`
#[derive(Debug, Clone)]
pub struct LayoutDecl {
pub name: String,
pub constraints: Vec<LayoutConstraint>,
pub span: Span,
}
/// A single layout constraint: `left op right [strength]`
#[derive(Debug, Clone)]
pub struct LayoutConstraint {
pub left: LayoutExpr,
pub op: ConstraintOp,
pub right: LayoutExpr,
pub strength: ConstraintStrength,
}
/// Constraint operators.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConstraintOp {
Eq, // ==
Gte, // >=
Lte, // <=
}
/// Constraint strength/priority.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ConstraintStrength {
Required,
Strong,
Medium,
Weak,
}
impl Default for ConstraintStrength {
fn default() -> Self { ConstraintStrength::Required }
}
/// A layout expression: `element.property`, `200`, or `expr + expr`.
#[derive(Debug, Clone)]
pub enum LayoutExpr {
/// `sidebar.width`, `parent.height`
Prop(String, String),
/// Numeric constant
Const(f64),
/// Addition: `sidebar.x + sidebar.width`
Add(Box<LayoutExpr>, Box<LayoutExpr>),
/// Subtraction: `parent.width - sidebar.width`
Sub(Box<LayoutExpr>, Box<LayoutExpr>),
/// Multiplication: `parent.width * 0.3`
Mul(Box<LayoutExpr>, Box<LayoutExpr>),
} }
/// `import { Card, Button } from "./components"` /// `import { Card, Button } from "./components"`

View file

@ -59,6 +59,7 @@ pub enum TokenKind {
Export, Export,
Type, Type,
Where, Where,
Layout,
// Operators // Operators
Plus, Plus,
@ -212,6 +213,7 @@ impl Lexer {
'+' => { self.advance(); Token { kind: TokenKind::Plus, lexeme: "+".into(), line, col } } '+' => { self.advance(); Token { kind: TokenKind::Plus, lexeme: "+".into(), line, col } }
'-' => { self.advance(); Token { kind: TokenKind::Minus, lexeme: "-".into(), line, col } } '-' => { self.advance(); Token { kind: TokenKind::Minus, lexeme: "-".into(), line, col } }
'*' => { self.advance(); Token { kind: TokenKind::Star, lexeme: "*".into(), line, col } } '*' => { self.advance(); Token { kind: TokenKind::Star, lexeme: "*".into(), line, col } }
'/' if self.peek_next() == '/' => self.lex_comment(),
'/' => { self.advance(); Token { kind: TokenKind::Slash, lexeme: "/".into(), line, col } } '/' => { self.advance(); Token { kind: TokenKind::Slash, lexeme: "/".into(), line, col } }
'%' => { self.advance(); Token { kind: TokenKind::Percent, lexeme: "%".into(), line, col } } '%' => { self.advance(); Token { kind: TokenKind::Percent, lexeme: "%".into(), line, col } }
'=' => { self.advance(); Token { kind: TokenKind::Eq, lexeme: "=".into(), line, col } } '=' => { self.advance(); Token { kind: TokenKind::Eq, lexeme: "=".into(), line, col } }
@ -338,6 +340,7 @@ impl Lexer {
"export" => TokenKind::Export, "export" => TokenKind::Export,
"type" => TokenKind::Type, "type" => TokenKind::Type,
"where" => TokenKind::Where, "where" => TokenKind::Where,
"layout" => TokenKind::Layout,
_ => TokenKind::Ident(ident.clone()), _ => TokenKind::Ident(ident.clone()),
}; };
@ -470,6 +473,15 @@ mod tests {
assert!(matches!(tokens[5].kind, TokenKind::Let)); assert!(matches!(tokens[5].kind, TokenKind::Let));
} }
#[test]
fn test_slash_comment() {
let mut lexer = Lexer::new("// this is a comment\nlet y = 10");
let tokens = lexer.tokenize();
// // comments are also skipped
assert!(matches!(tokens[0].kind, TokenKind::Newline));
assert!(matches!(tokens[1].kind, TokenKind::Let));
}
#[test] #[test]
fn test_string_interpolation_tokens() { fn test_string_interpolation_tokens() {
let mut lexer = Lexer::new(r#""Hello {name}!""#); let mut lexer = Lexer::new(r#""Hello {name}!""#);

View file

@ -102,13 +102,14 @@ impl Parser {
TokenKind::Import => self.parse_import_decl(), TokenKind::Import => self.parse_import_decl(),
TokenKind::Export => self.parse_export_decl(), TokenKind::Export => self.parse_export_decl(),
TokenKind::Type => self.parse_type_alias_decl(), TokenKind::Type => self.parse_type_alias_decl(),
TokenKind::Layout => self.parse_layout_decl(),
// Expression statement: `log("hello")`, `push(items, x)` // Expression statement: `log("hello")`, `push(items, x)`
TokenKind::Ident(_) => { TokenKind::Ident(_) => {
let expr = self.parse_expr()?; let expr = self.parse_expr()?;
Ok(Declaration::ExprStatement(expr)) Ok(Declaration::ExprStatement(expr))
} }
_ => Err(self.error(format!( _ => Err(self.error(format!(
"expected declaration (let, view, effect, on, component, route, constrain, stream, every, type), got {:?}", "expected declaration (let, view, effect, on, component, route, constrain, stream, every, type, layout), got {:?}",
self.peek() self.peek()
))), ))),
} }
@ -283,6 +284,125 @@ impl Parser {
} }
} }
/// Parse a layout declaration: `layout name { constraints }`
fn parse_layout_decl(&mut self) -> Result<Declaration, ParseError> {
let line = self.current_token().line;
self.advance(); // consume 'layout'
let name = self.expect_ident()?;
self.expect(&TokenKind::LBrace)?;
self.skip_newlines();
let mut constraints = Vec::new();
while !self.check(&TokenKind::RBrace) && !matches!(self.peek(), TokenKind::Eof) {
let constraint = self.parse_layout_constraint()?;
constraints.push(constraint);
self.skip_newlines();
}
self.expect(&TokenKind::RBrace)?;
Ok(Declaration::Layout(LayoutDecl {
name,
constraints,
span: Span { start: 0, end: 0, line },
}))
}
/// Parse a single constraint: `left_expr op right_expr [strength]`
fn parse_layout_constraint(&mut self) -> Result<LayoutConstraint, ParseError> {
let left = self.parse_layout_additive()?;
let op = match self.peek() {
TokenKind::EqEq => ConstraintOp::Eq,
TokenKind::Gte => ConstraintOp::Gte,
TokenKind::Lte => ConstraintOp::Lte,
other => return Err(self.error(format!("expected ==, >=, or <= in layout constraint, got {:?}", other))),
};
self.advance();
let right = self.parse_layout_additive()?;
// Optional strength: [required] [strong] [weak]
let strength = if self.check(&TokenKind::LBracket) {
self.advance();
let s = match self.peek() {
TokenKind::Ident(n) if n == "required" => ConstraintStrength::Required,
TokenKind::Ident(n) if n == "strong" => ConstraintStrength::Strong,
TokenKind::Ident(n) if n == "medium" => ConstraintStrength::Medium,
TokenKind::Ident(n) if n == "weak" => ConstraintStrength::Weak,
_ => ConstraintStrength::Required,
};
self.advance();
self.expect(&TokenKind::RBracket)?;
s
} else {
ConstraintStrength::Required
};
Ok(LayoutConstraint { left, op, right, strength })
}
/// Parse layout additive: `term (+ | - term)*`
fn parse_layout_additive(&mut self) -> Result<LayoutExpr, ParseError> {
let mut left = self.parse_layout_multiplicative()?;
loop {
if self.check(&TokenKind::Plus) {
self.advance();
let right = self.parse_layout_multiplicative()?;
left = LayoutExpr::Add(Box::new(left), Box::new(right));
} else if self.check(&TokenKind::Minus) {
self.advance();
let right = self.parse_layout_multiplicative()?;
left = LayoutExpr::Sub(Box::new(left), Box::new(right));
} else {
break;
}
}
Ok(left)
}
/// Parse layout multiplicative: `atom (* atom)*`
fn parse_layout_multiplicative(&mut self) -> Result<LayoutExpr, ParseError> {
let mut left = self.parse_layout_atom()?;
while self.check(&TokenKind::Star) {
self.advance();
let right = self.parse_layout_atom()?;
left = LayoutExpr::Mul(Box::new(left), Box::new(right));
}
Ok(left)
}
/// Parse layout atom: `element.prop` | `number` | `(expr)`
fn parse_layout_atom(&mut self) -> Result<LayoutExpr, ParseError> {
match self.peek().clone() {
TokenKind::Int(n) => {
self.advance();
Ok(LayoutExpr::Const(n as f64))
}
TokenKind::Float(f) => {
self.advance();
Ok(LayoutExpr::Const(f))
}
TokenKind::Ident(name) => {
self.advance();
if self.check(&TokenKind::Dot) {
self.advance();
let prop = self.expect_ident()?;
Ok(LayoutExpr::Prop(name, prop))
} else {
// Bare identifier treated as element.value
Ok(LayoutExpr::Prop(name, "value".to_string()))
}
}
TokenKind::LParen => {
self.advance();
let expr = self.parse_layout_additive()?;
self.expect(&TokenKind::RParen)?;
Ok(expr)
}
other => Err(self.error(format!("expected layout expression, got {:?}", other))),
}
}
fn parse_view_decl(&mut self) -> Result<Declaration, ParseError> { fn parse_view_decl(&mut self) -> Result<Declaration, ParseError> {
let line = self.current_token().line; let line = self.current_token().line;
self.advance(); // consume 'view' self.advance(); // consume 'view'
@ -1024,11 +1144,42 @@ impl Parser {
Ok(Expr::Call(format!("animate_{name}"), args)) Ok(Expr::Call(format!("animate_{name}"), args))
} }
// Identifier — variable reference, element, or function call // Identifier — variable reference, element, component use, or function call
TokenKind::Ident(name) => { TokenKind::Ident(name) => {
let name = name.clone(); let name = name.clone();
self.advance(); self.advance();
// Component use: `Button { label: "hello" }` — capitalized name + `{`
if name.chars().next().map_or(false, |c| c.is_uppercase())
&& self.check(&TokenKind::LBrace)
{
self.advance(); // consume `{`
self.skip_newlines();
let mut props = Vec::new();
let mut children = Vec::new();
while !self.check(&TokenKind::RBrace) && !self.is_at_end() {
self.skip_newlines();
// Check if this looks like a key: value prop or a child expression
let key = self.expect_ident()?;
if self.check(&TokenKind::Colon) {
self.advance(); // consume ':'
self.skip_newlines();
let val = self.parse_expr()?;
props.push((key, val));
} else {
// Bare ident — treat as child expression
children.push(Expr::Ident(key));
}
self.skip_newlines();
if self.check(&TokenKind::Comma) {
self.advance();
}
self.skip_newlines();
}
self.expect(&TokenKind::RBrace)?;
return Ok(Expr::ComponentUse { name, props, children });
}
// UI element with any arg (string, ident, parenthesized expr, or props-only) // UI element with any arg (string, ident, parenthesized expr, or props-only)
if is_ui_element(&name) { if is_ui_element(&name) {
let next = self.peek().clone(); let next = self.peek().clone();

15
examples/bench-signals.ds Normal file
View file

@ -0,0 +1,15 @@
let count = 0
let s1 = count + 1
let s2 = s1 + 1
let s3 = s2 + 1
let s4 = s3 + 1
let s5 = s4 + 1
let result = s5
on click -> count = count + 1
view main = column [
text "Signal Benchmark"
text result
button "Increment" { click: count += 1 }
]

19
examples/dashboard.ds Normal file
View file

@ -0,0 +1,19 @@
let title = "Dashboard"
let active = "Analytics"
layout dashboard {
sidebar.x == 0
sidebar.y == 0
sidebar.width == 250
sidebar.height == parent.height
main.x == sidebar.width
main.y == 0
main.width == parent.width - sidebar.width
main.height == parent.height
}
view main = column [
text title
text "Welcome to DreamStack Dashboard"
text active
]

45
examples/showcase.ds Normal file
View file

@ -0,0 +1,45 @@
-- DreamStack Component Showcase
-- Demonstrates all component styles
-- State
let name = ""
let count = 0
-- Main view
view main = column [
text "🧩 DreamStack Components" { class: "ds-card-title" }
text "shadcn-inspired component registry" { class: "ds-card-subtitle" }
-- Button Variants
text "Button Variants" { class: "ds-card-title" }
row [
button "Primary" { class: "ds-btn-primary" }
button "Secondary" { class: "ds-btn-secondary" }
button "Ghost" { class: "ds-btn-ghost" }
button "Destructive" { class: "ds-btn-destructive" }
]
-- Badge Variants
text "Badge Variants" { class: "ds-card-title" }
row [
text "SUCCESS" { class: "ds-badge ds-badge-success" }
text "WARNING" { class: "ds-badge ds-badge-warning" }
text "ERROR" { class: "ds-badge ds-badge-error" }
text "INFO" { class: "ds-badge ds-badge-info" }
text "DEFAULT" { class: "ds-badge ds-badge-default" }
]
-- Input with live binding
text "Input Component" { class: "ds-card-title" }
text "Name" { class: "ds-input-label" }
input { bind: name, placeholder: "Type your name..." }
text "Hello, {name}!"
-- Interactive counter
text "Interactive Counter" { class: "ds-card-title" }
row [
button "Count: {count}" { click: count += 1, class: "ds-btn-primary" }
button "Reset" { click: count = 0, class: "ds-btn-ghost" }
]
]

View file

@ -0,0 +1,5 @@
-- DreamStack Badge Component
-- Variants: success, warning, error, info, default
export component Badge(label, variant) =
text label { class: "ds-badge ds-badge-default" }

View file

@ -0,0 +1,5 @@
-- DreamStack Button Component
-- Variants: primary (default), secondary, ghost, destructive
export component Button(label, variant, onClick) =
button label { click: onClick, class: "ds-btn-primary" }

View file

@ -0,0 +1,8 @@
-- DreamStack Card Component
-- Glassmorphism container with title and subtitle
export component Card(title, subtitle) =
column [
text title { class: "ds-card-title" }
text subtitle { class: "ds-card-subtitle" }
] { class: "ds-card" }

View file

@ -0,0 +1,10 @@
-- DreamStack Dialog Component
-- Modal with overlay, title, and close button
import { Button } from "./button"
export component Dialog(title, open, onClose) =
column [
text title { class: "ds-dialog-title" }
Button { label: "Close", onClick: onClose }
] { class: "ds-dialog-content" }

View file

@ -0,0 +1,8 @@
-- DreamStack Input Component
-- Text input with label, placeholder, and error state
export component Input(value, placeholder, label) =
column [
text label { class: "ds-input-label" }
input { bind: value, placeholder: placeholder }
]

View file

@ -0,0 +1,5 @@
-- DreamStack Toast Component
-- Notification toast with slide-in animation
export component Toast(message) =
text message { class: "ds-toast" }

76
registry/registry.json Normal file
View file

@ -0,0 +1,76 @@
{
"$schema": "https://dreamstack.dev/schema/registry.json",
"name": "dreamstack",
"homepage": "https://dreamstack.dev",
"items": [
{
"name": "button",
"type": "registry:component",
"title": "Button",
"description": "A styled button with variant support (primary, secondary, ghost, destructive)",
"files": [
{
"path": "registry/components/button.ds"
}
]
},
{
"name": "input",
"type": "registry:component",
"title": "Input",
"description": "Text input with label, placeholder, and error state",
"files": [
{
"path": "registry/components/input.ds"
}
]
},
{
"name": "card",
"type": "registry:component",
"title": "Card",
"description": "Content container with title and styled border",
"files": [
{
"path": "registry/components/card.ds"
}
]
},
{
"name": "badge",
"type": "registry:component",
"title": "Badge",
"description": "Status badge with color variants (success, warning, error, info)",
"files": [
{
"path": "registry/components/badge.ds"
}
]
},
{
"name": "dialog",
"type": "registry:component",
"title": "Dialog",
"description": "Modal dialog with overlay and close button",
"registryDependencies": [
"button"
],
"files": [
{
"path": "registry/components/dialog.ds"
}
]
},
{
"name": "toast",
"type": "registry:component",
"title": "Toast",
"description": "Notification toast with auto-dismiss",
"files": [
{
"path": "registry/components/toast.ds"
}
]
}
]
}