diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index 89fdfb7..5bcba65 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -464,7 +464,7 @@ impl JsEmitter { } fn emit_component_decl(&mut self, comp: &ComponentDecl, graph: &SignalGraph) { - self.emit_line(&format!("function DS_{}(props) {{", comp.name)); + self.emit_line(&format!("function DS_{}(props, __children) {{", comp.name)); self.indent += 1; // Destructure props into local signal-compatible variables // Props may be raw values or signals — wrap in a signal-like accessor @@ -891,13 +891,30 @@ impl JsEmitter { container_var } - // Component usage: `Button { label: "hello" }` - Expr::ComponentUse { name, props, children: _ } => { + // Component usage: `Button { label: "hello" }` or `Card { title: "x" } [ children ]` + Expr::ComponentUse { name, props, children } => { let node_var = self.next_node_id(); let props_js: Vec = props.iter() .map(|(k, v)| format!("{}: {}", k, self.emit_expr(v))) .collect(); - self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", "))); + if children.is_empty() { + self.emit_line(&format!("const {} = DS_{}({{ {} }});", node_var, name, props_js.join(", "))); + } else { + // Build children factory function + let children_fn = self.next_node_id(); + self.emit_line(&format!("function {}() {{", children_fn)); + self.indent += 1; + let container = self.next_node_id(); + self.emit_line(&format!("const {} = document.createDocumentFragment();", container)); + for child in children { + let child_var = self.emit_view_expr(child, graph); + self.emit_line(&format!("{}.appendChild({});", container, child_var)); + } + self.emit_line(&format!("return {};", container)); + self.indent -= 1; + self.emit_line("}"); + self.emit_line(&format!("const {} = DS_{}({{ {} }}, {});", node_var, name, props_js.join(", "), children_fn)); + } node_var } @@ -929,6 +946,16 @@ impl JsEmitter { } + // Slot: render children passed to this component + Expr::Slot => { + let node_var = self.next_node_id(); + self.emit_line(&format!( + "const {} = __children ? __children() : document.createComment('empty-slot');", + node_var + )); + node_var + } + // Fallback: just create a text node _ => { let node_var = self.next_node_id(); diff --git a/compiler/ds-parser/src/ast.rs b/compiler/ds-parser/src/ast.rs index ec78e17..e336923 100644 --- a/compiler/ds-parser/src/ast.rs +++ b/compiler/ds-parser/src/ast.rs @@ -304,6 +304,8 @@ pub enum Expr { }, /// Index access: `grid[i]`, `pads[8 + i]` Index(Box, Box), + /// Slot: renders children passed to a component + Slot, } /// String literal with interpolation segments. diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index cddd620..27ad355 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -1169,6 +1169,11 @@ impl Parser { let name = name.clone(); self.advance(); + // Slot keyword: renders children inside a component + if name == "slot" { + return Ok(Expr::Slot); + } + // Component use: `Button { label: "hello" }` — capitalized name + `{` if name.chars().next().map_or(false, |c| c.is_uppercase()) && self.check(&TokenKind::LBrace) @@ -1197,6 +1202,18 @@ impl Parser { self.skip_newlines(); } self.expect(&TokenKind::RBrace)?; + // Check for [...] children block after props + self.skip_newlines(); + if self.check(&TokenKind::LBracket) { + self.advance(); // consume [ + self.skip_newlines(); + while !self.check(&TokenKind::RBracket) && !self.is_at_end() { + self.skip_newlines(); + children.push(self.parse_expr()?); + self.skip_newlines(); + } + self.expect(&TokenKind::RBracket)?; + } return Ok(Expr::ComponentUse { name, props, children }); } diff --git a/compiler/ds-types/src/checker.rs b/compiler/ds-types/src/checker.rs index aa2f82b..f328e80 100644 --- a/compiler/ds-types/src/checker.rs +++ b/compiler/ds-types/src/checker.rs @@ -697,6 +697,8 @@ impl TypeChecker { _ => self.fresh_tv(), } } + + Expr::Slot => Type::View, } } diff --git a/examples/slot-demo.ds b/examples/slot-demo.ds new file mode 100644 index 0000000..df7c0a0 --- /dev/null +++ b/examples/slot-demo.ds @@ -0,0 +1,24 @@ +-- DreamStack Slot Composition Demo +-- Demonstrates component children/slot rendering + +import { Card } from "../registry/components/card" +import { Button } from "../registry/components/button" +import { Badge } from "../registry/components/badge" + +let count = 0 + +view main = column [ + text "🧩 Slot Composition" { variant: "title" } + text "Components can now accept children!" { variant: "subtitle" } + + Card { title: "Counter Card", subtitle: "with interactive children" } [ + text "Count: {count}" + button "+1" { click: count += 1, variant: "primary" } + button "-1" { click: count -= 1, variant: "secondary" } + ] + + Card { title: "Nested Components", subtitle: "badge inside card" } [ + Badge { label: "nested", variant: "success" } + text "Badge rendered inside Card slot" + ] +] diff --git a/registry/components/card.ds b/registry/components/card.ds index d94ef80..93fa426 100644 --- a/registry/components/card.ds +++ b/registry/components/card.ds @@ -1,8 +1,9 @@ --- DreamStack Card Component --- Glassmorphism container with title and subtitle +-- DreamStack Card Component with Slot +-- Glassmorphism container with title, subtitle, and slot for children export component Card(title, subtitle) = column [ text title { variant: "title" } text subtitle { variant: "subtitle" } + slot ] { variant: "card" }