on master help sign in
Add pure functions with @fn definitions and @children slots
1105278iko
DESIGN.md+36
@@ -116,6 +116,42 @@ Creates a named attribute bundle. Referenced with `$name` inside attribute lists
116
116
117Attributes listed after `$name` override those in the definition.
117Attributes listed after `$name` override those in the definition.
118
118
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+
119## Text
155## Text
120
156
121### Bare text
157### Bare text
examples/demo.hl+21−12
@@ -1,7 +1,18 @@
1@page htmlang Demo
1@page htmlang Demo
2@let primary #3b82f6
2@let primary #3b82f6
3@let gap 20
3@let gap 20
4+
5+-- reusable card component
6+@fn card $title
4−@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
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
5
16
6@column [max-width 800, center-x, padding 40, spacing $gap]
17@column [max-width 800, center-x, padding 40, spacing $gap]
7 @row [spacing $gap]
18 @row [spacing $gap]
@@ -12,17 +23,15 @@
12 {@text [bold, color $primary] static HTML}.
23 {@text [bold, color $primary] static HTML}.
13 @image [width 80, height 80, rounded 40] https://picsum.photos/80
24 @image [width 80, height 80, rounded 40] https://picsum.photos/80
14
25
15− -- cards
26+ -- cards using @fn
27+ @page-section [title Features]
16− @row [wrap, spacing 10]
28+ @row [wrap, spacing 10]
17− [$card]
18− @text [bold] Simple
29+ @card [title Simple]
19− Write layouts without CSS
30+ Write layouts without CSS
20− [$card]
31+ @card [title Fast]
21− @text [bold] Fast
22− Compiles to a single HTML file
32+ Compiles to a single HTML file
23− [$card]
24− @text [bold] Minimal
33+ @card [title Minimal]
25− No JavaScript required
34+ No JavaScript required
26
35
27 -- footer
36 -- footer
28 @row [spacing 10]
37 @row [spacing 10]
examples/demo.html+2
@@ -17,6 +17,7 @@ img { display: block; }
17</div>
17</div>
18<img src="https://picsum.photos/80" style="width:80px;height:80px;border-radius:40px;">
18<img src="https://picsum.photos/80" style="width:80px;height:80px;border-radius:40px;">
19</div>
19</div>
20+<div style="display:flex;flex-direction:column;gap:10px;"><span style="font-weight:bold;font-size:24px;color:#333;">Features</span>
20<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>
21<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>
21<span>Write layouts without CSS</span></div>
22<span>Write layouts without CSS</span></div>
22<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>
23<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; }
24<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>
25<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>
25<span>No JavaScript required</span></div>
26<span>No JavaScript required</span></div>
26</div>
27</div>
28+</div>
27<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>
29<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>
28</a>
30</a>
29</div>
31</div>
src/ast.rs+1
@@ -39,6 +39,7 @@ pub enum ElementKind {
39 Paragraph,
39 Paragraph,
40 Image,
40 Image,
41 Link,
41 Link,
42+ Children,
42}
43}
43
44
44#[derive(Debug, Clone)]
45#[derive(Debug, Clone)]
src/codegen.rs+6−1
@@ -63,12 +63,17 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
63 return;
63 return;
64 }
64 }
65
65
66+ // @children should be replaced during function expansion; skip if it leaks through
67+ if elem.kind == ElementKind::Children {
68+ return;
69+ }
70+
66 let tag = match elem.kind {
71 let tag = match elem.kind {
67 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
72 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
68 ElementKind::Text => "span",
73 ElementKind::Text => "span",
69 ElementKind::Paragraph => "p",
74 ElementKind::Paragraph => "p",
70 ElementKind::Link => "a",
75 ElementKind::Link => "a",
71− ElementKind::Image => unreachable!(),
76+ ElementKind::Image | ElementKind::Children => unreachable!(),
72 };
77 };
73
78
74 let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
79 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 {
15 }
15 }
16}
16}
17
17
18+#[derive(Clone)]
18enum LineContent {
19enum LineContent {
19 Normal(String),
20 Normal(String),
20 Raw(String),
21 Raw(String),
21}
22}
22
23
24+#[derive(Clone)]
23struct Line {
25struct Line {
24 indent: usize,
26 indent: usize,
25 content: LineContent,
27 content: LineContent,
26 line_num: usize,
28 line_num: usize,
27}
29}
28
30
31+#[derive(Clone)]
32+struct FnDef {
33+ params: Vec<String>,
34+ body_lines: Vec<Line>,
35+}
36+
29struct ParseContext {
37struct ParseContext {
30 page_title: Option<String>,
38 page_title: Option<String>,
31 variables: HashMap<String, String>,
39 variables: HashMap<String, String>,
32 defines: HashMap<String, Vec<Attribute>>,
40 defines: HashMap<String, Vec<Attribute>>,
41+ functions: HashMap<String, FnDef>,
33}
42}
34
43
35struct Parser {
44struct Parser {
@@ -44,6 +53,7 @@ pub fn parse(input: &str) -> Result<Document, ParseError> {
44 page_title: None,
53 page_title: None,
45 variables: HashMap::new(),
54 variables: HashMap::new(),
46 defines: HashMap::new(),
55 defines: HashMap::new(),
56+ functions: HashMap::new(),
47 };
57 };
48 let nodes = parser.parse_children(0, &mut ctx)?;
58 let nodes = parser.parse_children(0, &mut ctx)?;
49 Ok(Document {
59 Ok(Document {
@@ -150,15 +160,18 @@ impl Parser {
150 }
160 }
151
161
152 match self.parse_line(ctx)? {
162 match self.parse_line(ctx)? {
153− Some(node) => nodes.push(node),
163+ Some(new_nodes) => nodes.extend(new_nodes),
154− None => {} // directive consumed, no node
164+ None => {}
155 }
165 }
156 }
166 }
157
167
158 Ok(nodes)
168 Ok(nodes)
159 }
169 }
160
170
171+ fn parse_line(
172+ &mut self,
173+ ctx: &mut ParseContext,
161− fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Node>, ParseError> {
174+ ) -> Result<Option<Vec<Node>>, ParseError> {
162 let line_num = self.lines[self.pos].line_num;
175 let line_num = self.lines[self.pos].line_num;
163 let current_indent = self.lines[self.pos].indent;
176 let current_indent = self.lines[self.pos].indent;
164
177
@@ -166,7 +179,7 @@ impl Parser {
166 if let LineContent::Raw(s) = &self.lines[self.pos].content {
179 if let LineContent::Raw(s) = &self.lines[self.pos].content {
167 let content = s.clone();
180 let content = s.clone();
168 self.pos += 1;
181 self.pos += 1;
169− return Ok(Some(Node::Raw(content)));
182+ return Ok(Some(vec![Node::Raw(content)]));
170 }
183 }
171
184
172 // Normal content — clone to release borrow
185 // Normal content — clone to release borrow
@@ -208,17 +221,123 @@ impl Parser {
208 return Ok(None);
221 return Ok(None);
209 }
222 }
210
223
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+
211 // --- Elements ---
262 // --- Elements ---
212
263
213 if content.starts_with('@') || content.starts_with('[') {
264 if content.starts_with('@') || content.starts_with('[') {
214 let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
265 let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
215− return Ok(Some(node));
266+ return Ok(Some(vec![node]));
216 }
267 }
217
268
218 // --- Bare text ---
269 // --- Bare text ---
219
270
220 let segments = parse_text_segments(&content, ctx);
271 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))
222 }
341 }
223
342
224 fn parse_element_line(
343 fn parse_element_line(
@@ -251,10 +370,39 @@ impl Parser {
251 }
370 }
252}
371}
253
372
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+
254// ---------------------------------------------------------------------------
394// ---------------------------------------------------------------------------
255// Element parsing
395// Element parsing
256// ---------------------------------------------------------------------------
396// ---------------------------------------------------------------------------
257
397
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+
258fn parse_single_element(
406fn parse_single_element(
259 content: &str,
407 content: &str,
260 line_num: usize,
408 line_num: usize,
@@ -329,6 +477,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
329 "paragraph" | "p" => Ok(ElementKind::Paragraph),
477 "paragraph" | "p" => Ok(ElementKind::Paragraph),
330 "image" | "img" => Ok(ElementKind::Image),
478 "image" | "img" => Ok(ElementKind::Image),
331 "link" => Ok(ElementKind::Link),
479 "link" => Ok(ElementKind::Link),
480+ "children" => Ok(ElementKind::Children),
332 _ => Err(ParseError {
481 _ => Err(ParseError {
333 line: line_num,
482 line: line_num,
334 message: format!("unknown element @{}", s),
483 message: format!("unknown element @{}", s),
@@ -560,7 +709,9 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
560 let mut i = 0;
709 let mut i = 0;
561
710
562 while i < chars.len() {
711 while i < chars.len() {
712+ if chars[i] == '$'
713+ && i + 1 < chars.len()
563− if chars[i] == '$' && i + 1 < chars.len() && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
714+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
564 {
715 {
565 let start = i + 1;
716 let start = i + 1;
566 let mut end = start;
717 let mut end = start;