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:
parent
06cd2371d5
commit
adff40d47f
5 changed files with 205 additions and 5 deletions
|
|
@ -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 };
|
||||
})();
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
35
examples/router.ds
Normal 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!"
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue