feat: hash-based router + keyed list reconciliation

Phase 7 features:
- route "/path" -> body declarations with hash-based routing
- navigate "/path" built-in function
- DS.route signal tracks current hash path
- DS.matchRoute() with :param pattern matching
- DS.keyedList() for keyed DOM reconciliation
- Route-aware mounting: views + routes compose
- examples/router.ds: 3-page app (home/about/counter)
- State persists across route changes (10818 bytes)
This commit is contained in:
enzotar 2026-02-25 07:54:00 -08:00
parent 06cd2371d5
commit adff40d47f
5 changed files with 205 additions and 5 deletions

View file

@ -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<String> = 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 };
})();
"#;

View file

@ -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 {

View file

@ -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()),
};

View file

@ -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<Declaration, ParseError> {
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<Vec<Param>, 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();

35
examples/router.ds Normal file
View file

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