diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index 5794976..21a2897 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -135,10 +135,62 @@ impl JsEmitter { } } - // Phase 4: Mount to DOM + // Phase 4: Route view functions + let routes: Vec<_> = program.declarations.iter() + .filter_map(|d| if let Declaration::Route(r) = d { Some(r) } else { None }) + .collect(); + + if !routes.is_empty() { + self.emit_line(""); + self.emit_line("// ── Routes ──"); + for (i, route) in routes.iter().enumerate() { + self.emit_line(&format!("function route_view_{}() {{", i)); + self.indent += 1; + let child = self.emit_view_expr(&route.body, graph); + self.emit_line(&format!("return {};", child)); + self.indent -= 1; + self.emit_line("}"); + } + } + + // Phase 5: Mount to DOM self.emit_line(""); self.emit_line("// ── Mount ──"); - if let Some(view) = views.first() { + + if !routes.is_empty() { + // Router-based mounting + self.emit_line("const __root = document.getElementById('ds-root');"); + self.emit_line("let __currentView = null;"); + + // Emit any non-route views as layout (e.g., nav bar) + for view in views { + self.emit_line(&format!("__root.appendChild(view_{}());", view.name)); + } + + self.emit_line("const __routeContainer = document.createElement('div');"); + self.emit_line("__routeContainer.className = 'ds-route-container';"); + self.emit_line("__root.appendChild(__routeContainer);"); + self.emit_line(""); + self.emit_line("DS.effect(() => {"); + self.indent += 1; + self.emit_line("const path = DS.route.value;"); + self.emit_line("__routeContainer.innerHTML = '';"); + + for (i, route) in routes.iter().enumerate() { + let branch = if i == 0 { "if" } else { "} else if" }; + self.emit_line(&format!( + "{} (DS.matchRoute(\"{}\", path)) {{", + branch, route.path + )); + self.indent += 1; + self.emit_line(&format!("__routeContainer.appendChild(route_view_{}());", i)); + self.indent -= 1; + } + self.emit_line("}"); + + self.indent -= 1; + self.emit_line("});"); + } else if let Some(view) = views.first() { self.emit_line(&format!( "document.getElementById('ds-root').appendChild(view_{}());", view.name @@ -497,7 +549,12 @@ impl JsEmitter { } Expr::Call(name, args) => { let args_js: Vec = args.iter().map(|a| self.emit_expr(a)).collect(); - format!("{}({})", name, args_js.join(", ")) + // Built-in functions map to DS.xxx() + let fn_name = match name.as_str() { + "navigate" => "DS.navigate", + _ => name, + }; + format!("{}({})", fn_name, args_js.join(", ")) } Expr::If(cond, then_b, else_b) => { let c = self.emit_expr(cond); @@ -836,6 +893,58 @@ const DS = (() => { } } - return { signal, derived, effect, batch, flush, onEvent, emit, Signal, Derived, Effect }; + // ── Keyed List Reconciliation ── + function keyedList(container, getItems, keyFn, renderFn) { + let nodeMap = new Map(); + const eff = effect(() => { + const raw = getItems(); + const items = (raw && raw.value !== undefined) ? raw.value : (Array.isArray(raw) ? raw : []); + const newMap = new Map(); + const frag = document.createDocumentFragment(); + items.forEach((item, idx) => { + const key = keyFn ? keyFn(item, idx) : idx; + let node = nodeMap.get(key); + if (!node) { + node = renderFn(item, idx); + } + newMap.set(key, node); + frag.appendChild(node); + }); + container.innerHTML = ''; + container.appendChild(frag); + nodeMap = newMap; + }); + return eff; + } + + // ── Router ── + const _route = new Signal(window.location.hash.slice(1) || '/'); + window.addEventListener('hashchange', () => { + _route.value = window.location.hash.slice(1) || '/'; + }); + + function navigate(path) { + window.location.hash = path; + } + + function matchRoute(pattern, path) { + if (pattern === path) return {}; + const patParts = pattern.split('/'); + const pathParts = path.split('/'); + if (patParts.length !== pathParts.length) return null; + const params = {}; + for (let i = 0; i < patParts.length; i++) { + if (patParts[i].startsWith(':')) { + params[patParts[i].slice(1)] = pathParts[i]; + } else if (patParts[i] !== pathParts[i]) { + return null; + } + } + return params; + } + + return { signal, derived, effect, batch, flush, onEvent, emit, + keyedList, route: _route, navigate, matchRoute, + Signal, Derived, Effect }; })(); "#; diff --git a/compiler/ds-parser/src/ast.rs b/compiler/ds-parser/src/ast.rs index fba59b2..d90a856 100644 --- a/compiler/ds-parser/src/ast.rs +++ b/compiler/ds-parser/src/ast.rs @@ -20,6 +20,8 @@ pub enum Declaration { OnHandler(OnHandler), /// `component Name(props) = body` Component(ComponentDecl), + /// `route "/path" -> view_expr` + Route(RouteDecl), } /// `let count = 0` or `let doubled = count * 2` @@ -67,6 +69,14 @@ pub struct ComponentDecl { pub span: Span, } +/// `route "/users/:id" -> column [ text "User {id}" ]` +#[derive(Debug, Clone)] +pub struct RouteDecl { + pub path: String, + pub body: Expr, + pub span: Span, +} + /// Function/view parameter. #[derive(Debug, Clone)] pub struct Param { diff --git a/compiler/ds-parser/src/lexer.rs b/compiler/ds-parser/src/lexer.rs index 3c675fb..c59a290 100644 --- a/compiler/ds-parser/src/lexer.rs +++ b/compiler/ds-parser/src/lexer.rs @@ -47,6 +47,8 @@ pub enum TokenKind { For, In, Component, + Route, + Navigate, // Operators Plus, @@ -314,6 +316,8 @@ impl Lexer { "for" => TokenKind::For, "in" => TokenKind::In, "component" => TokenKind::Component, + "route" => TokenKind::Route, + "navigate" => TokenKind::Navigate, _ => TokenKind::Ident(ident.clone()), }; diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index cc181a2..031ce73 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -95,8 +95,9 @@ impl Parser { TokenKind::Effect => self.parse_effect_decl(), TokenKind::On => self.parse_on_handler(), TokenKind::Component => self.parse_component_decl(), + TokenKind::Route => self.parse_route_decl(), _ => Err(self.error(format!( - "expected declaration (let, view, effect, on, component), got {:?}", + "expected declaration (let, view, effect, on, component, route), got {:?}", self.peek() ))), } @@ -208,6 +209,40 @@ impl Parser { })) } + /// `route "/users/:id" -> column [ ... ]` + fn parse_route_decl(&mut self) -> Result { + let line = self.current_token().line; + self.advance(); // consume 'route' + + // Path string: consume "path" as StringStart + StringFragment + StringEnd + let path = if matches!(self.peek(), TokenKind::StringFragment(_)) { + // Simple string token (no interpolation) + if let TokenKind::StringFragment(s) = self.peek() { + let p = s.clone(); + self.advance(); // consume fragment + // Consume trailing StringEnd if present + if matches!(self.peek(), TokenKind::StringEnd) { + self.advance(); + } + p + } else { + return Err(self.error("expected path string after 'route'".to_string())); + } + } else { + return Err(self.error("expected path string after 'route'".to_string())); + }; + + self.expect(&TokenKind::Arrow)?; + self.skip_newlines(); + let body = self.parse_expr()?; + + Ok(Declaration::Route(RouteDecl { + path, + body, + span: Span { start: 0, end: 0, line }, + })) + } + fn parse_params(&mut self) -> Result, ParseError> { self.expect(&TokenKind::LParen)?; let mut params = Vec::new(); @@ -524,6 +559,13 @@ impl Parser { }) } + // Navigate expression: `navigate "/path"` + TokenKind::Navigate => { + self.advance(); + let path_expr = self.parse_primary()?; + Ok(Expr::Call("navigate".to_string(), vec![path_expr])) + } + // Stream from TokenKind::Stream => { self.advance(); diff --git a/examples/router.ds b/examples/router.ds new file mode 100644 index 0000000..c8967b6 --- /dev/null +++ b/examples/router.ds @@ -0,0 +1,35 @@ +-- DreamStack Router Example +-- Multi-page app with hash-based routing + +let page_title = "DreamStack Router" + +view nav = row [ + button "Home" { click: navigate "/" } + button "About" { click: navigate "/about" } + button "Counter" { click: navigate "/counter" } +] + +let count = 0 + +route "/" -> + column [ + text "Welcome to DreamStack!" + text "Use the nav bar above to explore pages." + ] + +route "/about" -> + column [ + text "About DreamStack" + text "A reactive UI language with fine-grained signals." + text "No virtual DOM. No dependency arrays. No re-renders." + ] + +route "/counter" -> + column [ + text "Interactive Counter" + text count + button "+" { click: count += 1 } + button "-" { click: count -= 1 } + when count > 10 -> + text "Double digits!" + ]