on master help sign in
Add pure functions with @fn definitions and @children slots
commit1105278a1e00e7077961c073f929f117d35e0261 authoriko <mail@iko.soy> dateTue 14 Apr 2026, 13:20 (6 months ago) parent3ca0ba6
6 files changed+224−20
DESIGN.md+36 examples/demo.hl+21−12 examples/demo.html+2 src/ast.rs+1 src/codegen.rs+6−1 src/parser.rs+158−7
DESIGN.md+36
@@ -116,6 +116,42 @@ Creates a named attribute bundle. Referenced with `$name` inside attribute lists
116116
117117Attributes listed after `$name` override those in the definition.
118118
119+### `@fn`
120+
121+Defines a pure function (reusable component). Parameters are prefixed with `$`.
122+
123+```
124+@fn card $title
125+ @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
126+ @text [bold, size 18] $title
127+ @children
128+```
129+
130+Call it like any element, passing parameters in `[...]`:
131+
132+```
133+@card [title Hello World]
134+ This is the card body.
135+ @text [italic] With styled text.
136+```
137+
138+### `@children`
139+
140+A slot inside a function body that expands to the caller's indented children.
141+
142+```
143+@fn layout $title
144+ @column [max-width 800, center-x, padding 40]
145+ @text [bold, size 32] $title
146+ @children
147+
148+@layout [title My Page]
149+ @row [spacing 10]
150+ Content goes here
151+```
152+
153+Functions can call other functions. `@let` and `@define` variables are available inside function bodies. Function parameters shadow `@let` variables of the same name within the body.
154+
119155## Text
120156
121157### Bare text
examples/demo.hl+21−12
@@ -1,7 +1,18 @@
11@page htmlang Demo
22@let primary #3b82f6
33@let gap 20
4−@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
4+
5+-- reusable card component
6+@fn card $title
7+ @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
8+ @text [bold] $title
9+ @children
10+
11+-- page layout function
12+@fn page-section $title
13+ @column [spacing 10]
14+ @text [bold, size 24, color #333] $title
15+ @children
516
617@column [max-width 800, center-x, padding 40, spacing $gap]
718 @row [spacing $gap]
@@ -12,17 +23,15 @@
1223 {@text [bold, color $primary] static HTML}.
1324 @image [width 80, height 80, rounded 40] https://picsum.photos/80
1425
15− -- cards
16− @row [wrap, spacing 10]
17− [$card]
18− @text [bold] Simple
19− Write layouts without CSS
20− [$card]
21− @text [bold] Fast
22− Compiles to a single HTML file
23− [$card]
24− @text [bold] Minimal
25− No JavaScript required
26+ -- cards using @fn
27+ @page-section [title Features]
28+ @row [wrap, spacing 10]
29+ @card [title Simple]
30+ Write layouts without CSS
31+ @card [title Fast]
32+ Compiles to a single HTML file
33+ @card [title Minimal]
34+ No JavaScript required
2635
2736 -- footer
2837 @row [spacing 10]
examples/demo.html+2
@@ -17,6 +17,7 @@ img { display: block; }
1717</div>
1818<img src="https://picsum.photos/80" style="width:80px;height:80px;border-radius:40px;">
1919</div>
20+<div style="display:flex;flex-direction:column;gap:10px;"><span style="font-weight:bold;font-size:24px;color:#333;">Features</span>
2021<div style="display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;"><div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Simple</span>
2122<span>Write layouts without CSS</span></div>
2223<div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Fast</span>
@@ -24,6 +25,7 @@ img { display: block; }
2425<div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Minimal</span>
2526<span>No JavaScript required</span></div>
2627</div>
28+</div>
2729<div style="display:flex;flex-direction:row;gap:10px;"><div style="display:flex;flex-direction:column;padding:16px;background:#3b82f6;border-radius:8px;"><a href="https://github.com"><span style="color:white;font-weight:bold;">Get Started</span>
2830</a>
2931</div>
src/ast.rs+1
@@ -39,6 +39,7 @@ pub enum ElementKind {
3939 Paragraph,
4040 Image,
4141 Link,
42+ Children,
4243}
4344
4445#[derive(Debug, Clone)]
src/codegen.rs+6−1
@@ -63,12 +63,17 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
6363 return;
6464 }
6565
66+ // @children should be replaced during function expansion; skip if it leaks through
67+ if elem.kind == ElementKind::Children {
68+ return;
69+ }
70+
6671 let tag = match elem.kind {
6772 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
6873 ElementKind::Text => "span",
6974 ElementKind::Paragraph => "p",
7075 ElementKind::Link => "a",
71− ElementKind::Image => unreachable!(),
76+ ElementKind::Image | ElementKind::Children => unreachable!(),
7277 };
7378
7479 let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
src/parser.rs+158−7
@@ -15,21 +15,30 @@ impl fmt::Display for ParseError {
1515 }
1616}
1717
18+#[derive(Clone)]
1819enum LineContent {
1920 Normal(String),
2021 Raw(String),
2122}
2223
24+#[derive(Clone)]
2325struct Line {
2426 indent: usize,
2527 content: LineContent,
2628 line_num: usize,
2729}
2830
31+#[derive(Clone)]
32+struct FnDef {
33+ params: Vec<String>,
34+ body_lines: Vec<Line>,
35+}
36+
2937struct ParseContext {
3038 page_title: Option<String>,
3139 variables: HashMap<String, String>,
3240 defines: HashMap<String, Vec<Attribute>>,
41+ functions: HashMap<String, FnDef>,
3342}
3443
3544struct Parser {
@@ -44,6 +53,7 @@ pub fn parse(input: &str) -> Result<Document, ParseError> {
4453 page_title: None,
4554 variables: HashMap::new(),
4655 defines: HashMap::new(),
56+ functions: HashMap::new(),
4757 };
4858 let nodes = parser.parse_children(0, &mut ctx)?;
4959 Ok(Document {
@@ -150,15 +160,18 @@ impl Parser {
150160 }
151161
152162 match self.parse_line(ctx)? {
153− Some(node) => nodes.push(node),
154− None => {} // directive consumed, no node
163+ Some(new_nodes) => nodes.extend(new_nodes),
164+ None => {}
155165 }
156166 }
157167
158168 Ok(nodes)
159169 }
160170
161− fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Node>, ParseError> {
171+ fn parse_line(
172+ &mut self,
173+ ctx: &mut ParseContext,
174+ ) -> Result<Option<Vec<Node>>, ParseError> {
162175 let line_num = self.lines[self.pos].line_num;
163176 let current_indent = self.lines[self.pos].indent;
164177
@@ -166,7 +179,7 @@ impl Parser {
166179 if let LineContent::Raw(s) = &self.lines[self.pos].content {
167180 let content = s.clone();
168181 self.pos += 1;
169− return Ok(Some(Node::Raw(content)));
182+ return Ok(Some(vec![Node::Raw(content)]));
170183 }
171184
172185 // Normal content — clone to release borrow
@@ -208,17 +221,123 @@ impl Parser {
208221 return Ok(None);
209222 }
210223
224+ // --- Function definition ---
225+
226+ if let Some(rest) = content.strip_prefix("@fn ") {
227+ let parts: Vec<&str> = rest.split_whitespace().collect();
228+ if parts.is_empty() {
229+ return Err(ParseError {
230+ line: line_num,
231+ message: "@fn requires a name".to_string(),
232+ });
233+ }
234+ let name = parts[0].to_string();
235+ let params: Vec<String> = parts[1..]
236+ .iter()
237+ .map(|p| p.strip_prefix('$').unwrap_or(p).to_string())
238+ .collect();
239+
240+ // Collect body lines (all lines indented deeper than @fn)
241+ let mut body_lines = Vec::new();
242+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
243+ body_lines.push(self.lines[self.pos].clone());
244+ self.pos += 1;
245+ }
246+
247+ ctx.functions.insert(name, FnDef { params, body_lines });
248+ return Ok(None);
249+ }
250+
251+ // --- Function call ---
252+
253+ if content.starts_with('@') {
254+ let name = extract_element_name(&content);
255+ if ctx.functions.contains_key(name) {
256+ let nodes =
257+ self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
258+ return Ok(Some(nodes));
259+ }
260+ }
261+
211262 // --- Elements ---
212263
213264 if content.starts_with('@') || content.starts_with('[') {
214265 let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
215− return Ok(Some(node));
266+ return Ok(Some(vec![node]));
216267 }
217268
218269 // --- Bare text ---
219270
220271 let segments = parse_text_segments(&content, ctx);
221− Ok(Some(Node::Text(segments)))
272+ Ok(Some(vec![Node::Text(segments)]))
273+ }
274+
275+ fn expand_fn_call(
276+ &mut self,
277+ name: &str,
278+ content: &str,
279+ current_indent: usize,
280+ line_num: usize,
281+ ctx: &mut ParseContext,
282+ ) -> Result<Vec<Node>, ParseError> {
283+ // Parse [param value, ...] arguments
284+ let rest = &content[1 + name.len()..];
285+ let rest = rest.trim_start();
286+
287+ let args = if rest.starts_with('[') {
288+ let (attrs, _) = parse_attr_brackets(rest, line_num, ctx)?;
289+ attrs
290+ } else {
291+ Vec::new()
292+ };
293+
294+ // Clone function definition (releases borrow on ctx)
295+ let fn_def = ctx.functions.get(name).unwrap().clone();
296+
297+ // Parse caller's children
298+ let caller_children = self.parse_children(current_indent + 1, ctx)?;
299+
300+ // Save variable state, inject function parameters
301+ let saved_vars = ctx.variables.clone();
302+ for (i, param) in fn_def.params.iter().enumerate() {
303+ let value = args
304+ .iter()
305+ .find(|a| a.key == *param)
306+ .and_then(|a| a.value.clone())
307+ .or_else(|| args.get(i).and_then(|a| a.value.clone()))
308+ .unwrap_or_default();
309+ ctx.variables.insert(param.clone(), value);
310+ }
311+
312+ // Normalize body indentation so it parses from indent 0
313+ let min_indent = fn_def
314+ .body_lines
315+ .iter()
316+ .map(|l| l.indent)
317+ .min()
318+ .unwrap_or(0);
319+ let adjusted: Vec<Line> = fn_def
320+ .body_lines
321+ .iter()
322+ .map(|l| Line {
323+ indent: l.indent - min_indent,
324+ content: l.content.clone(),
325+ line_num: l.line_num,
326+ })
327+ .collect();
328+
329+ // Parse body with params in scope
330+ let mut body_parser = Parser {
331+ lines: adjusted,
332+ pos: 0,
333+ };
334+ let body_nodes = body_parser.parse_children(0, ctx)?;
335+
336+ // Restore variables
337+ ctx.variables = saved_vars;
338+
339+ // Replace @children with caller's children
340+ Ok(replace_children_nodes(body_nodes, &caller_children))
222341 }
223342
224343 fn parse_element_line(
@@ -251,10 +370,39 @@ impl Parser {
251370 }
252371}
253372
373+// ---------------------------------------------------------------------------
374+// @children replacement
375+// ---------------------------------------------------------------------------
376+
377+fn replace_children_nodes(nodes: Vec<Node>, caller_children: &[Node]) -> Vec<Node> {
378+ let mut result = Vec::new();
379+ for node in nodes {
380+ match node {
381+ Node::Element(elem) if elem.kind == ElementKind::Children => {
382+ result.extend(caller_children.iter().cloned());
383+ }
384+ Node::Element(mut elem) => {
385+ elem.children = replace_children_nodes(elem.children, caller_children);
386+ result.push(Node::Element(elem));
387+ }
388+ other => result.push(other),
389+ }
390+ }
391+ result
392+}
393+
254394// ---------------------------------------------------------------------------
255395// Element parsing
256396// ---------------------------------------------------------------------------
257397
398+fn extract_element_name(content: &str) -> &str {
399+ let without_at = &content[1..];
400+ match without_at.find(|c: char| c == ' ' || c == '[') {
401+ Some(i) => &without_at[..i],
402+ None => without_at,
403+ }
404+}
405+
258406fn parse_single_element(
259407 content: &str,
260408 line_num: usize,
@@ -329,6 +477,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
329477 "paragraph" | "p" => Ok(ElementKind::Paragraph),
330478 "image" | "img" => Ok(ElementKind::Image),
331479 "link" => Ok(ElementKind::Link),
480+ "children" => Ok(ElementKind::Children),
332481 _ => Err(ParseError {
333482 line: line_num,
334483 message: format!("unknown element @{}", s),
@@ -560,7 +709,9 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
560709 let mut i = 0;
561710
562711 while i < chars.len() {
563− if chars[i] == '$' && i + 1 < chars.len() && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
712+ if chars[i] == '$'
713+ && i + 1 < chars.len()
714+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
564715 {
565716 let start = i + 1;
566717 let mut end = start;