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:
parent
61c26acfa7
commit
7805b94704
22 changed files with 2431 additions and 11 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
17
bench/Cargo.toml
Normal file
17
bench/Cargo.toml
Normal 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"] }
|
||||
181
bench/benches/compiler_bench.rs
Normal file
181
bench/benches/compiler_bench.rs
Normal 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
1
bench/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub fn add(a: i32, b: i32) -> i32 { a + b }
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 ──");
|
||||
for decl in &program.declarations {
|
||||
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
|
||||
let streams: Vec<_> = program.declarations.iter()
|
||||
.filter_map(|d| if let Declaration::Stream(s) = d { Some(s) } else { None })
|
||||
|
|
@ -376,6 +463,19 @@ impl JsEmitter {
|
|||
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.
|
||||
fn emit_view_expr(&mut self, expr: &Expr, graph: &SignalGraph) -> String {
|
||||
match expr {
|
||||
|
|
@ -640,14 +740,13 @@ impl JsEmitter {
|
|||
container_var
|
||||
}
|
||||
|
||||
// Component usage: `<Card title="hello" />`
|
||||
Expr::ComponentUse { name, props, children } => {
|
||||
let args = props.iter()
|
||||
.map(|(k, v)| self.emit_expr(v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
// Component usage: `Button { label: "hello" }`
|
||||
Expr::ComponentUse { name, props, children: _ } => {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -678,6 +777,7 @@ impl JsEmitter {
|
|||
container_var
|
||||
}
|
||||
|
||||
|
||||
// Fallback: just create a text node
|
||||
_ => {
|
||||
let node_var = self.next_node_id();
|
||||
|
|
@ -708,7 +808,7 @@ impl JsEmitter {
|
|||
|
||||
// ── Expression emitters ─────────────────────────────
|
||||
|
||||
fn emit_expr(&self, expr: &Expr) -> String {
|
||||
pub fn emit_expr(&self, expr: &Expr) -> String {
|
||||
match expr {
|
||||
Expr::IntLit(n) => format!("{n}"),
|
||||
Expr::FloatLit(n) => format!("{n}"),
|
||||
|
|
@ -897,6 +997,43 @@ impl JsEmitter {
|
|||
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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1094,6 +1231,35 @@ impl JsEmitter {
|
|||
&& 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
|
||||
fn emit_scene(&mut self, container: &Container, graph: &SignalGraph) -> String {
|
||||
let wrapper_var = self.next_node_id();
|
||||
|
|
@ -1454,6 +1620,117 @@ body {
|
|||
.ds-button:active {
|
||||
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 {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
|
@ -1463,6 +1740,22 @@ body {
|
|||
font-size: 1rem;
|
||||
outline: none;
|
||||
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 {
|
||||
border-color: #6366f1;
|
||||
|
|
|
|||
9
compiler/ds-incremental/Cargo.toml
Normal file
9
compiler/ds-incremental/Cargo.toml
Normal 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 }
|
||||
283
compiler/ds-incremental/src/lib.rs
Normal file
283
compiler/ds-incremental/src/lib.rs
Normal 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(_)));
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,61 @@ pub enum Declaration {
|
|||
Export(String, Box<Declaration>),
|
||||
/// `type PositiveInt = Int where value > 0`
|
||||
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"`
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ pub enum TokenKind {
|
|||
Export,
|
||||
Type,
|
||||
Where,
|
||||
Layout,
|
||||
|
||||
// Operators
|
||||
Plus,
|
||||
|
|
@ -212,6 +213,7 @@ impl Lexer {
|
|||
'+' => { 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::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::Percent, lexeme: "%".into(), line, col } }
|
||||
'=' => { self.advance(); Token { kind: TokenKind::Eq, lexeme: "=".into(), line, col } }
|
||||
|
|
@ -338,6 +340,7 @@ impl Lexer {
|
|||
"export" => TokenKind::Export,
|
||||
"type" => TokenKind::Type,
|
||||
"where" => TokenKind::Where,
|
||||
"layout" => TokenKind::Layout,
|
||||
_ => TokenKind::Ident(ident.clone()),
|
||||
};
|
||||
|
||||
|
|
@ -470,6 +473,15 @@ mod tests {
|
|||
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]
|
||||
fn test_string_interpolation_tokens() {
|
||||
let mut lexer = Lexer::new(r#""Hello {name}!""#);
|
||||
|
|
|
|||
|
|
@ -102,13 +102,14 @@ impl Parser {
|
|||
TokenKind::Import => self.parse_import_decl(),
|
||||
TokenKind::Export => self.parse_export_decl(),
|
||||
TokenKind::Type => self.parse_type_alias_decl(),
|
||||
TokenKind::Layout => self.parse_layout_decl(),
|
||||
// Expression statement: `log("hello")`, `push(items, x)`
|
||||
TokenKind::Ident(_) => {
|
||||
let expr = self.parse_expr()?;
|
||||
Ok(Declaration::ExprStatement(expr))
|
||||
}
|
||||
_ => 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()
|
||||
))),
|
||||
}
|
||||
|
|
@ -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> {
|
||||
let line = self.current_token().line;
|
||||
self.advance(); // consume 'view'
|
||||
|
|
@ -1024,11 +1144,42 @@ impl Parser {
|
|||
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) => {
|
||||
let name = name.clone();
|
||||
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)
|
||||
if is_ui_element(&name) {
|
||||
let next = self.peek().clone();
|
||||
|
|
|
|||
15
examples/bench-signals.ds
Normal file
15
examples/bench-signals.ds
Normal 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
19
examples/dashboard.ds
Normal 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
45
examples/showcase.ds
Normal 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" }
|
||||
]
|
||||
]
|
||||
5
registry/components/badge.ds
Normal file
5
registry/components/badge.ds
Normal 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" }
|
||||
5
registry/components/button.ds
Normal file
5
registry/components/button.ds
Normal 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" }
|
||||
8
registry/components/card.ds
Normal file
8
registry/components/card.ds
Normal 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" }
|
||||
10
registry/components/dialog.ds
Normal file
10
registry/components/dialog.ds
Normal 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" }
|
||||
8
registry/components/input.ds
Normal file
8
registry/components/input.ds
Normal 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 }
|
||||
]
|
||||
5
registry/components/toast.ds
Normal file
5
registry/components/toast.ds
Normal 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
76
registry/registry.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue