on master help sign in
Initial implementation of htmlang compiler
3ca0ba6iko
.gitignorenew file+2
@@ -0,0 +1,2 @@
1+/target
2+*.html
Cargo.locknew file+7
@@ -0,0 +1,7 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "htmlang"
7+version = "0.1.0"
Cargo.tomlnew file+6
@@ -0,0 +1,6 @@
1+[package]
2+name = "htmlang"
3+version = "0.1.0"
4+edition = "2024"
5+
6+[dependencies]
DESIGN.mdnew file+273
@@ -0,0 +1,273 @@
1+# htmlang (.hl)
2+
3+A minimalist layout language inspired by elm-ui that compiles to static HTML.
4+
5+## Principles
6+
7+- `@` means structure, bare lines mean content
8+- Layout is explicit and compositional — no CSS cascade
9+- Every element declares its own layout role
10+- Output is a self-contained HTML file with embedded CSS (flexbox)
11+
12+## Elements
13+
14+| Element | Output | Purpose |
15+|--------------|------------------|--------------------------------|
16+| `@row` | flex row | Horizontal layout |
17+| `@column` | flex column | Vertical layout |
18+| `@el` | div | Generic container |
19+| `@text` | span | Styled inline text |
20+| `@paragraph` | p with spans | Flowing/wrapping inline text |
21+| `@image` | img | Image |
22+| `@link` | a | Anchor wrapping children |
23+| `@raw` | verbatim HTML | Escape hatch |
24+
25+Bare lines (not starting with `@` or `[`) are text nodes.
26+
27+## Syntax
28+
29+### Basic structure
30+
31+```
32+@element [attributes]
33+ children
34+```
35+
36+Children are indented under their parent. Attributes are optional, comma-separated inside `[...]`.
37+
38+### Full example
39+
40+```
41+-- My Site
42+@page My Site
43+@let primary #3b82f6
44+@let gap 20
45+@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
46+
47+@column [max-width 800, center-x, padding 40, spacing $gap]
48+ @row [spacing $gap]
49+ @column [width fill, spacing 10]
50+ @text [bold, size 32] Welcome
51+ @paragraph
52+ This is a page built with {@text [bold, color $primary] htmlang}.
53+ Read the {@link https://docs.example.com docs} to learn more.
54+ @image [width 80, height 80, rounded 40] avatar.png
55+
56+ -- cards
57+ @row [wrap, spacing 10]
58+ [$card]
59+ First card
60+ [$card, background #f9fafb]
61+ Second card
62+
63+ -- footer
64+ @row [spacing 10]
65+ @el [padding 16, background $primary, rounded 8] > @link https://example.com
66+ @text [color white] Get Started
67+ @el [width fill]
68+ @text [color #888] © 2026
69+
70+ @raw """
71+ <canvas id="chart"></canvas>
72+ """
73+```
74+
75+## Directives
76+
77+### `@page`
78+
79+Sets the HTML `<title>` and generates boilerplate (`<!DOCTYPE>`, `<html>`, `<head>`, `<body>`).
80+
81+```
82+@page My Site
83+```
84+
85+### `@let`
86+
87+Defines a variable. Referenced with `$name`.
88+
89+```
90+@let primary #3b82f6
91+@let gap 20
92+
93+@el [background $primary, spacing $gap]
94+```
95+
96+### `@include`
97+
98+Inlines another `.hl` file.
99+
100+```
101+@include header.hl
102+```
103+
104+### `@define`
105+
106+Creates a named attribute bundle. Referenced with `$name` inside attribute lists.
107+
108+```
109+@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
110+
111+@el [$card]
112+ Content
113+@el [$card, background #f9fafb]
114+ Overridden background
115+```
116+
117+Attributes listed after `$name` override those in the definition.
118+
119+## Text
120+
121+### Bare text
122+
123+Any line not starting with `@` or `[` is a text node.
124+
125+```
126+@column
127+ Hello world
128+ This is just text
129+```
130+
131+### Styled text
132+
133+Use `@text` when you need attributes.
134+
135+```
136+@text [bold, size 24, color #333] Hello world
137+```
138+
139+### Inline elements
140+
141+Use `{...}` to embed elements inside text lines. Essential for mixed-style paragraphs.
142+
143+```
144+@paragraph
145+ This is {@text [bold] important} and this is a {@link https://example.com link}.
146+```
147+
148+## Comments
149+
150+`--` starts a comment. The rest of the line is ignored.
151+
152+```
153+-- this is a comment
154+@row [spacing 10]
155+ -- todo: add nav items
156+```
157+
158+## Implicit `@el`
159+
160+A line starting with `[` (attributes) that has indented children is an anonymous container (implicit `@el`).
161+
162+```
163+[padding 20, background white]
164+ Hello
165+
166+-- equivalent to:
167+@el [padding 20, background white]
168+ Hello
169+```
170+
171+## Single-child chaining
172+
173+Use `>` to chain single-child elements on one line, reducing nesting depth.
174+
175+```
176+@el [padding 16, background blue, rounded 8] > @link https://example.com
177+ @text [color white] Get Started
178+
179+-- equivalent to:
180+@el [padding 16, background blue, rounded 8]
181+ @link https://example.com
182+ @text [color white] Get Started
183+```
184+
185+The last element in the chain takes the indented children.
186+
187+## `@raw`
188+
189+Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or JS.
190+
191+```
192+@raw """
193+<style>
194+ @keyframes spin { to { transform: rotate(360deg); } }
195+</style>
196+<div class="custom-widget"></div>
197+"""
198+```
199+
200+## Attributes reference
201+
202+### Layout (set on parent)
203+
204+| Attribute | Effect |
205+|------------------------|----------------------------------|
206+| `spacing N` | Gap between children |
207+| `padding N` | Uniform padding |
208+| `padding-x N` | Horizontal padding |
209+| `padding-y N` | Vertical padding |
210+| `padding T R B L` | Per-side padding |
211+
212+### Sizing (set on child)
213+
214+| Attribute | Effect |
215+|------------------------|----------------------------------|
216+| `width fill` | Take remaining space (flex: 1) |
217+| `width N` | Exact pixels |
218+| `width shrink` | Fit to content (default) |
219+| `height fill` | Take remaining space |
220+| `height N` | Exact pixels |
221+| `height shrink` | Fit to content (default) |
222+| `min-width N` | Minimum width |
223+| `max-width N` | Maximum width |
224+| `min-height N` | Minimum height |
225+| `max-height N` | Maximum height |
226+
227+### Alignment (set on child)
228+
229+| Attribute | Effect |
230+|------------------------|----------------------------------|
231+| `center-x` | Center horizontally |
232+| `center-y` | Center vertically |
233+| `align-left` | Align to left |
234+| `align-right` | Align to right |
235+| `align-top` | Align to top |
236+| `align-bottom` | Align to bottom |
237+
238+### Style
239+
240+| Attribute | Effect |
241+|------------------------|----------------------------------|
242+| `background COLOR` | Background color |
243+| `color COLOR` | Text color |
244+| `border N COLOR` | Border width and color |
245+| `rounded N` | Border radius |
246+| `bold` | Bold text |
247+| `italic` | Italic text |
248+| `underline` | Underlined text |
249+| `size N` | Font size in px |
250+| `font NAME` | Font family |
251+
252+### Flow
253+
254+| Attribute | Effect |
255+|------------------------|----------------------------------|
256+| `wrap` | Enable flex-wrap (on `@row`) |
257+
258+### Identity
259+
260+| Attribute | Effect |
261+|------------------------|----------------------------------|
262+| `id NAME` | HTML id attribute |
263+| `class NAME` | HTML class attribute |
264+
265+## Compilation target
266+
267+Each `.hl` file compiles to a single self-contained `.html` file:
268+
269+- Elements map to `<div>`, `<span>`, `<p>`, `<a>`, `<img>` as appropriate
270+- Layout uses flexbox (`display: flex`, `flex-direction`, `gap`, etc.)
271+- Styles are scoped via generated class names in an embedded `<style>` block
272+- No external CSS, no JavaScript (unless injected via `@raw`)
273+- `@page` generates the HTML boilerplate; without it, output is an HTML fragment
examples/demo.hlnew file+32
@@ -0,0 +1,32 @@
1+@page htmlang Demo
2+@let primary #3b82f6
3+@let gap 20
4+@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
5+
6+@column [max-width 800, center-x, padding 40, spacing $gap]
7+ @row [spacing $gap]
8+ @column [width fill, spacing 10]
9+ @text [bold, size 32] Welcome to htmlang
10+ @paragraph
11+ A minimalist layout language that compiles to
12+ {@text [bold, color $primary] static HTML}.
13+ @image [width 80, height 80, rounded 40] https://picsum.photos/80
14+
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+
27+ -- footer
28+ @row [spacing 10]
29+ @el [padding 16, background $primary, rounded 8] > @link https://github.com
30+ @text [color white, bold] Get Started
31+ @el [width fill]
32+ @text [color #888] © 2026
examples/demo.htmlnew file+35
@@ -0,0 +1,35 @@
1+<!DOCTYPE html>
2+<html>
3+<head>
4+<meta charset="utf-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
6+<title>htmlang Demo</title>
7+<style>
8+*, *::before, *::after { box-sizing: border-box; }
9+body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
10+img { display: block; }
11+</style>
12+</head>
13+<body>
14+<div style="display:flex;flex-direction:column;max-width:800px;margin-left:auto;margin-right:auto;padding:40px;gap:20px;"><div style="display:flex;flex-direction:row;gap:20px;"><div style="display:flex;flex-direction:column;flex:1;min-width:0;gap:10px;"><span style="font-weight:bold;font-size:32px;">Welcome to htmlang</span>
15+<p style="margin:0;">A minimalist layout language that compiles to
16+<span style="font-weight:bold;color:#3b82f6;">static HTML</span>.</p>
17+</div>
18+<img src="https://picsum.photos/80" style="width:80px;height:80px;border-radius:40px;">
19+</div>
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+<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+<span>Compiles to a single HTML file</span></div>
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+<span>No JavaScript required</span></div>
26+</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>
28+</a>
29+</div>
30+<div style="display:flex;flex-direction:column;flex:1;min-width:0;"></div>
31+<span style="color:#888;">© 2026</span>
32+</div>
33+</div>
34+</body>
35+</html>
src/ast.rsnew file+48
@@ -0,0 +1,48 @@
1+use std::collections::HashMap;
2+
3+#[derive(Debug)]
4+#[allow(dead_code)]
5+pub struct Document {
6+ pub page_title: Option<String>,
7+ pub variables: HashMap<String, String>,
8+ pub defines: HashMap<String, Vec<Attribute>>,
9+ pub nodes: Vec<Node>,
10+}
11+
12+#[derive(Debug, Clone)]
13+pub enum Node {
14+ Element(Element),
15+ Text(Vec<TextSegment>),
16+ Raw(String),
17+}
18+
19+#[derive(Debug, Clone)]
20+pub enum TextSegment {
21+ Plain(String),
22+ Inline(Element),
23+}
24+
25+#[derive(Debug, Clone)]
26+pub struct Element {
27+ pub kind: ElementKind,
28+ pub attrs: Vec<Attribute>,
29+ pub argument: Option<String>,
30+ pub children: Vec<Node>,
31+}
32+
33+#[derive(Debug, Clone, PartialEq)]
34+pub enum ElementKind {
35+ Row,
36+ Column,
37+ El,
38+ Text,
39+ Paragraph,
40+ Image,
41+ Link,
42+}
43+
44+#[derive(Debug, Clone)]
45+pub struct Attribute {
46+ pub key: String,
47+ pub value: Option<String>,
48+}
src/codegen.rsnew file+428
@@ -0,0 +1,428 @@
1+use crate::ast::*;
2+
3+pub fn generate(doc: &Document) -> String {
4+ let mut body = String::new();
5+ for node in &doc.nodes {
6+ generate_node(node, None, &mut body);
7+ }
8+
9+ match &doc.page_title {
10+ Some(title) => format!(
11+ "\
12+<!DOCTYPE html>
13+<html>
14+<head>
15+<meta charset=\"utf-8\">
16+<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
17+<title>{title}</title>
18+<style>
19+*, *::before, *::after {{ box-sizing: border-box; }}
20+body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
21+img {{ display: block; }}
22+</style>
23+</head>
24+<body>
25+{body}\
26+</body>
27+</html>
28+",
29+ title = html_escape(title),
30+ body = body,
31+ ),
32+ None => body,
33+ }
34+}
35+
36+fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut String) {
37+ match node {
38+ Node::Element(elem) => generate_element(elem, parent_kind, out),
39+ Node::Text(segments) => {
40+ let needs_wrap = matches!(
41+ parent_kind,
42+ Some(ElementKind::Row) | Some(ElementKind::Column) | Some(ElementKind::El)
43+ );
44+ if needs_wrap {
45+ out.push_str("<span>");
46+ }
47+ generate_text_segments(segments, out);
48+ if needs_wrap {
49+ out.push_str("</span>");
50+ }
51+ }
52+ Node::Raw(content) => {
53+ out.push_str(content);
54+ out.push('\n');
55+ }
56+ }
57+}
58+
59+fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut String) {
60+ // Image is a void element
61+ if elem.kind == ElementKind::Image {
62+ generate_image(elem, parent_kind, out);
63+ return;
64+ }
65+
66+ let tag = match elem.kind {
67+ ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
68+ ElementKind::Text => "span",
69+ ElementKind::Paragraph => "p",
70+ ElementKind::Link => "a",
71+ ElementKind::Image => unreachable!(),
72+ };
73+
74+ let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
75+ let (id, class) = extract_id_class(&elem.attrs);
76+
77+ out.push('<');
78+ out.push_str(tag);
79+
80+ if elem.kind == ElementKind::Link {
81+ if let Some(url) = &elem.argument {
82+ out.push_str(" href=\"");
83+ out.push_str(&html_escape(url));
84+ out.push('"');
85+ }
86+ }
87+
88+ if !styles.is_empty() {
89+ out.push_str(" style=\"");
90+ out.push_str(&styles);
91+ out.push('"');
92+ }
93+ if let Some(id) = id {
94+ out.push_str(" id=\"");
95+ out.push_str(&html_escape(&id));
96+ out.push('"');
97+ }
98+ if let Some(class) = class {
99+ out.push_str(" class=\"");
100+ out.push_str(&html_escape(&class));
101+ out.push('"');
102+ }
103+ out.push('>');
104+
105+ // Inline text argument (@text content)
106+ if elem.kind == ElementKind::Text {
107+ if let Some(text) = &elem.argument {
108+ out.push_str(&html_escape(text));
109+ }
110+ }
111+
112+ // Children
113+ let is_paragraph = elem.kind == ElementKind::Paragraph;
114+ for (i, child) in elem.children.iter().enumerate() {
115+ generate_node(child, Some(&elem.kind), out);
116+ // In paragraphs, add newline between children so HTML renders a space
117+ if is_paragraph && i < elem.children.len() - 1 {
118+ out.push('\n');
119+ }
120+ }
121+
122+ out.push_str("</");
123+ out.push_str(tag);
124+ out.push_str(">\n");
125+}
126+
127+fn generate_image(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut String) {
128+ let src = elem.argument.as_deref().unwrap_or("");
129+ let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
130+ let (id, class) = extract_id_class(&elem.attrs);
131+
132+ out.push_str("<img src=\"");
133+ out.push_str(&html_escape(src));
134+ out.push('"');
135+
136+ if !styles.is_empty() {
137+ out.push_str(" style=\"");
138+ out.push_str(&styles);
139+ out.push('"');
140+ }
141+ if let Some(id) = id {
142+ out.push_str(" id=\"");
143+ out.push_str(&html_escape(&id));
144+ out.push('"');
145+ }
146+ if let Some(class) = class {
147+ out.push_str(" class=\"");
148+ out.push_str(&html_escape(&class));
149+ out.push('"');
150+ }
151+ out.push_str(">\n");
152+}
153+
154+fn generate_text_segments(segments: &[TextSegment], out: &mut String) {
155+ for segment in segments {
156+ match segment {
157+ TextSegment::Plain(text) => out.push_str(&html_escape(text)),
158+ TextSegment::Inline(elem) => {
159+ // Render to buffer and trim trailing newlines for inline context
160+ let mut buf = String::new();
161+ generate_element(elem, None, &mut buf);
162+ out.push_str(buf.trim_end());
163+ }
164+ }
165+ }
166+}
167+
168+// ---------------------------------------------------------------------------
169+// Attribute → CSS mapping
170+// ---------------------------------------------------------------------------
171+
172+fn attrs_to_css(
173+ attrs: &[Attribute],
174+ kind: &ElementKind,
175+ parent_kind: Option<&ElementKind>,
176+) -> String {
177+ let mut css = String::new();
178+
179+ // Base styles from element kind
180+ match kind {
181+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
182+ ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
183+ ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
184+ ElementKind::Paragraph => css.push_str("margin:0;"),
185+ _ => {}
186+ }
187+
188+ for attr in attrs {
189+ let val = attr.value.as_deref();
190+
191+ match attr.key.as_str() {
192+ // Layout
193+ "spacing" => {
194+ if let Some(v) = val {
195+ push_css(&mut css, "gap", &format!("{}px", v));
196+ }
197+ }
198+ "padding" => {
199+ if let Some(v) = val {
200+ let parts: Vec<&str> = v.split_whitespace().collect();
201+ match parts.len() {
202+ 1 => push_css(&mut css, "padding", &format!("{}px", parts[0])),
203+ 2 => push_css(
204+ &mut css,
205+ "padding",
206+ &format!("{}px {}px", parts[0], parts[1]),
207+ ),
208+ 4 => push_css(
209+ &mut css,
210+ "padding",
211+ &format!(
212+ "{}px {}px {}px {}px",
213+ parts[0], parts[1], parts[2], parts[3]
214+ ),
215+ ),
216+ _ => {}
217+ }
218+ }
219+ }
220+ "padding-x" => {
221+ if let Some(v) = val {
222+ push_css(&mut css, "padding-left", &format!("{}px", v));
223+ push_css(&mut css, "padding-right", &format!("{}px", v));
224+ }
225+ }
226+ "padding-y" => {
227+ if let Some(v) = val {
228+ push_css(&mut css, "padding-top", &format!("{}px", v));
229+ push_css(&mut css, "padding-bottom", &format!("{}px", v));
230+ }
231+ }
232+
233+ // Sizing
234+ "width" => {
235+ if let Some(v) = val {
236+ match v {
237+ "fill" => match parent_kind {
238+ Some(ElementKind::Row) => {
239+ push_css(&mut css, "flex", "1");
240+ push_css(&mut css, "min-width", "0");
241+ }
242+ _ => push_css(&mut css, "width", "100%"),
243+ },
244+ "shrink" => {
245+ push_css(&mut css, "flex-shrink", "0");
246+ }
247+ _ => push_css(&mut css, "width", &format!("{}px", v)),
248+ }
249+ }
250+ }
251+ "height" => {
252+ if let Some(v) = val {
253+ match v {
254+ "fill" => match parent_kind {
255+ Some(ElementKind::Column) => {
256+ push_css(&mut css, "flex", "1");
257+ push_css(&mut css, "min-height", "0");
258+ }
259+ _ => push_css(&mut css, "height", "100%"),
260+ },
261+ "shrink" => {
262+ push_css(&mut css, "flex-shrink", "0");
263+ }
264+ _ => push_css(&mut css, "height", &format!("{}px", v)),
265+ }
266+ }
267+ }
268+ "min-width" => {
269+ if let Some(v) = val {
270+ push_css(&mut css, "min-width", &format!("{}px", v));
271+ }
272+ }
273+ "max-width" => {
274+ if let Some(v) = val {
275+ push_css(&mut css, "max-width", &format!("{}px", v));
276+ }
277+ }
278+ "min-height" => {
279+ if let Some(v) = val {
280+ push_css(&mut css, "min-height", &format!("{}px", v));
281+ }
282+ }
283+ "max-height" => {
284+ if let Some(v) = val {
285+ push_css(&mut css, "max-height", &format!("{}px", v));
286+ }
287+ }
288+
289+ // Alignment
290+ "center-x" => match parent_kind {
291+ Some(ElementKind::Column) | Some(ElementKind::El) => {
292+ push_css(&mut css, "align-self", "center");
293+ }
294+ _ => {
295+ push_css(&mut css, "margin-left", "auto");
296+ push_css(&mut css, "margin-right", "auto");
297+ }
298+ },
299+ "center-y" => match parent_kind {
300+ Some(ElementKind::Row) => {
301+ push_css(&mut css, "align-self", "center");
302+ }
303+ _ => {
304+ push_css(&mut css, "margin-top", "auto");
305+ push_css(&mut css, "margin-bottom", "auto");
306+ }
307+ },
308+ "align-left" => match parent_kind {
309+ Some(ElementKind::Column) | Some(ElementKind::El) => {
310+ push_css(&mut css, "align-self", "flex-start");
311+ }
312+ _ => {
313+ push_css(&mut css, "margin-right", "auto");
314+ }
315+ },
316+ "align-right" => match parent_kind {
317+ Some(ElementKind::Column) | Some(ElementKind::El) => {
318+ push_css(&mut css, "align-self", "flex-end");
319+ }
320+ _ => {
321+ push_css(&mut css, "margin-left", "auto");
322+ }
323+ },
324+ "align-top" => match parent_kind {
325+ Some(ElementKind::Row) => {
326+ push_css(&mut css, "align-self", "flex-start");
327+ }
328+ _ => {
329+ push_css(&mut css, "margin-bottom", "auto");
330+ }
331+ },
332+ "align-bottom" => match parent_kind {
333+ Some(ElementKind::Row) => {
334+ push_css(&mut css, "align-self", "flex-end");
335+ }
336+ _ => {
337+ push_css(&mut css, "margin-top", "auto");
338+ }
339+ },
340+
341+ // Style
342+ "background" => {
343+ if let Some(v) = val {
344+ push_css(&mut css, "background", v);
345+ }
346+ }
347+ "color" => {
348+ if let Some(v) = val {
349+ push_css(&mut css, "color", v);
350+ }
351+ }
352+ "border" => {
353+ if let Some(v) = val {
354+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
355+ if parts.len() == 2 {
356+ push_css(
357+ &mut css,
358+ "border",
359+ &format!("{}px solid {}", parts[0], parts[1]),
360+ );
361+ } else {
362+ push_css(
363+ &mut css,
364+ "border",
365+ &format!("{}px solid currentColor", parts[0]),
366+ );
367+ }
368+ }
369+ }
370+ "rounded" => {
371+ if let Some(v) = val {
372+ push_css(&mut css, "border-radius", &format!("{}px", v));
373+ }
374+ }
375+ "bold" => push_css(&mut css, "font-weight", "bold"),
376+ "italic" => push_css(&mut css, "font-style", "italic"),
377+ "underline" => push_css(&mut css, "text-decoration", "underline"),
378+ "size" => {
379+ if let Some(v) = val {
380+ push_css(&mut css, "font-size", &format!("{}px", v));
381+ }
382+ }
383+ "font" => {
384+ if let Some(v) = val {
385+ push_css(&mut css, "font-family", v);
386+ }
387+ }
388+
389+ // Flow
390+ "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
391+
392+ // Identity — handled separately, not CSS
393+ "id" | "class" => {}
394+
395+ // Unknown — ignore
396+ _ => {}
397+ }
398+ }
399+
400+ css
401+}
402+
403+fn push_css(css: &mut String, prop: &str, value: &str) {
404+ css.push_str(prop);
405+ css.push(':');
406+ css.push_str(value);
407+ css.push(';');
408+}
409+
410+fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
411+ let mut id = None;
412+ let mut class = None;
413+ for attr in attrs {
414+ match attr.key.as_str() {
415+ "id" => id = attr.value.clone(),
416+ "class" => class = attr.value.clone(),
417+ _ => {}
418+ }
419+ }
420+ (id, class)
421+}
422+
423+fn html_escape(s: &str) -> String {
424+ s.replace('&', "&amp;")
425+ .replace('<', "&lt;")
426+ .replace('>', "&gt;")
427+ .replace('"', "&quot;")
428+}
src/main.rsnew file+44
@@ -0,0 +1,44 @@
1+use std::env;
2+use std::fs;
3+use std::path::Path;
4+use std::process;
5+
6+mod ast;
7+mod codegen;
8+mod parser;
9+
10+fn main() {
11+ let args: Vec<String> = env::args().collect();
12+ if args.len() < 2 {
13+ eprintln!("Usage: htmlang <file.hl>");
14+ process::exit(1);
15+ }
16+
17+ let input_path = &args[1];
18+ let input = match fs::read_to_string(input_path) {
19+ Ok(s) => s,
20+ Err(e) => {
21+ eprintln!("error: {}: {}", input_path, e);
22+ process::exit(1);
23+ }
24+ };
25+
26+ let doc = match parser::parse(&input) {
27+ Ok(d) => d,
28+ Err(e) => {
29+ eprintln!("parse error: {}", e);
30+ process::exit(1);
31+ }
32+ };
33+
34+ let html = codegen::generate(&doc);
35+
36+ let out_path = Path::new(input_path).with_extension("html");
37+ match fs::write(&out_path, &html) {
38+ Ok(()) => eprintln!("wrote {}", out_path.display()),
39+ Err(e) => {
40+ eprintln!("error: {}: {}", out_path.display(), e);
41+ process::exit(1);
42+ }
43+ }
44+}
src/parser.rsnew file+587
@@ -0,0 +1,587 @@
1+use std::collections::HashMap;
2+use std::fmt;
3+
4+use crate::ast::*;
5+
6+#[derive(Debug)]
7+pub struct ParseError {
8+ pub line: usize,
9+ pub message: String,
10+}
11+
12+impl fmt::Display for ParseError {
13+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
14+ write!(f, "line {}: {}", self.line, self.message)
15+ }
16+}
17+
18+enum LineContent {
19+ Normal(String),
20+ Raw(String),
21+}
22+
23+struct Line {
24+ indent: usize,
25+ content: LineContent,
26+ line_num: usize,
27+}
28+
29+struct ParseContext {
30+ page_title: Option<String>,
31+ variables: HashMap<String, String>,
32+ defines: HashMap<String, Vec<Attribute>>,
33+}
34+
35+struct Parser {
36+ lines: Vec<Line>,
37+ pos: usize,
38+}
39+
40+pub fn parse(input: &str) -> Result<Document, ParseError> {
41+ let lines = preprocess(input);
42+ let mut parser = Parser { lines, pos: 0 };
43+ let mut ctx = ParseContext {
44+ page_title: None,
45+ variables: HashMap::new(),
46+ defines: HashMap::new(),
47+ };
48+ let nodes = parser.parse_children(0, &mut ctx)?;
49+ Ok(Document {
50+ page_title: ctx.page_title,
51+ variables: ctx.variables,
52+ defines: ctx.defines,
53+ nodes,
54+ })
55+}
56+
57+// ---------------------------------------------------------------------------
58+// Preprocessing: strip comments/blanks, collapse @raw blocks
59+// ---------------------------------------------------------------------------
60+
61+fn preprocess(input: &str) -> Vec<Line> {
62+ let raw_lines: Vec<&str> = input.lines().collect();
63+ let mut lines = Vec::new();
64+ let mut i = 0;
65+
66+ while i < raw_lines.len() {
67+ let line = raw_lines[i];
68+ let trimmed = line.trim();
69+
70+ if trimmed.is_empty() || trimmed.starts_with("--") {
71+ i += 1;
72+ continue;
73+ }
74+
75+ let indent = line.len() - line.trim_start().len();
76+
77+ // Handle @raw """..."""
78+ if trimmed.starts_with("@raw") {
79+ let after_raw = trimmed[4..].trim_start();
80+ if after_raw.starts_with("\"\"\"") {
81+ let after_open = &after_raw[3..];
82+
83+ // Single-line: @raw """content"""
84+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
85+ let content = &after_open[..after_open.len() - 3];
86+ lines.push(Line {
87+ indent,
88+ content: LineContent::Raw(content.to_string()),
89+ line_num: i + 1,
90+ });
91+ i += 1;
92+ continue;
93+ }
94+
95+ // Multiline: collect until closing """
96+ let mut raw_content = String::new();
97+ if !after_open.is_empty() {
98+ raw_content.push_str(after_open);
99+ raw_content.push('\n');
100+ }
101+ i += 1;
102+ while i < raw_lines.len() {
103+ if raw_lines[i].trim() == "\"\"\"" {
104+ i += 1;
105+ break;
106+ }
107+ raw_content.push_str(raw_lines[i]);
108+ raw_content.push('\n');
109+ i += 1;
110+ }
111+
112+ lines.push(Line {
113+ indent,
114+ content: LineContent::Raw(
115+ raw_content.trim_end_matches('\n').to_string(),
116+ ),
117+ line_num: i,
118+ });
119+ continue;
120+ }
121+ }
122+
123+ lines.push(Line {
124+ indent,
125+ content: LineContent::Normal(trimmed.to_string()),
126+ line_num: i + 1,
127+ });
128+ i += 1;
129+ }
130+
131+ lines
132+}
133+
134+// ---------------------------------------------------------------------------
135+// Parser
136+// ---------------------------------------------------------------------------
137+
138+impl Parser {
139+ fn parse_children(
140+ &mut self,
141+ min_indent: usize,
142+ ctx: &mut ParseContext,
143+ ) -> Result<Vec<Node>, ParseError> {
144+ let mut nodes = Vec::new();
145+
146+ while self.pos < self.lines.len() {
147+ let indent = self.lines[self.pos].indent;
148+ if indent < min_indent {
149+ break;
150+ }
151+
152+ match self.parse_line(ctx)? {
153+ Some(node) => nodes.push(node),
154+ None => {} // directive consumed, no node
155+ }
156+ }
157+
158+ Ok(nodes)
159+ }
160+
161+ fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Node>, ParseError> {
162+ let line_num = self.lines[self.pos].line_num;
163+ let current_indent = self.lines[self.pos].indent;
164+
165+ // Handle raw content
166+ if let LineContent::Raw(s) = &self.lines[self.pos].content {
167+ let content = s.clone();
168+ self.pos += 1;
169+ return Ok(Some(Node::Raw(content)));
170+ }
171+
172+ // Normal content — clone to release borrow
173+ let content = match &self.lines[self.pos].content {
174+ LineContent::Normal(s) => s.clone(),
175+ _ => unreachable!(),
176+ };
177+ self.pos += 1;
178+
179+ // --- Directives ---
180+
181+ if let Some(rest) = content.strip_prefix("@page ") {
182+ ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
183+ return Ok(None);
184+ }
185+
186+ if let Some(rest) = content.strip_prefix("@let ") {
187+ let rest = rest.trim();
188+ if let Some((name, value)) = rest.split_once(' ') {
189+ ctx.variables
190+ .insert(name.to_string(), substitute_vars(value.trim(), &ctx.variables));
191+ }
192+ return Ok(None);
193+ }
194+
195+ if let Some(rest) = content.strip_prefix("@define ") {
196+ let rest = rest.trim();
197+ if let Some(bracket_start) = rest.find('[') {
198+ let name = rest[..bracket_start].trim();
199+ let attrs_str = &rest[bracket_start..];
200+ let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
201+ ctx.defines.insert(name.to_string(), attrs);
202+ }
203+ return Ok(None);
204+ }
205+
206+ if content.starts_with("@include ") {
207+ // TODO: implement file includes
208+ return Ok(None);
209+ }
210+
211+ // --- Elements ---
212+
213+ if content.starts_with('@') || content.starts_with('[') {
214+ let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
215+ return Ok(Some(node));
216+ }
217+
218+ // --- Bare text ---
219+
220+ let segments = parse_text_segments(&content, ctx);
221+ Ok(Some(Node::Text(segments)))
222+ }
223+
224+ fn parse_element_line(
225+ &mut self,
226+ content: &str,
227+ current_indent: usize,
228+ line_num: usize,
229+ ctx: &mut ParseContext,
230+ ) -> Result<Node, ParseError> {
231+ let segments = split_chain(content);
232+
233+ // Parse each segment into an Element
234+ let mut elements: Vec<Element> = Vec::new();
235+ for seg in &segments {
236+ let elem = parse_single_element(seg.trim(), line_num, ctx)?;
237+ elements.push(elem);
238+ }
239+
240+ // Parse indented children (belong to the innermost element)
241+ let children = self.parse_children(current_indent + 1, ctx)?;
242+
243+ // Build chain right-to-left: rightmost gets children, each wraps the next
244+ let mut current_children = children;
245+ for mut elem in elements.into_iter().rev() {
246+ elem.children.extend(current_children);
247+ current_children = vec![Node::Element(elem)];
248+ }
249+
250+ Ok(current_children.into_iter().next().unwrap())
251+ }
252+}
253+
254+// ---------------------------------------------------------------------------
255+// Element parsing
256+// ---------------------------------------------------------------------------
257+
258+fn parse_single_element(
259+ content: &str,
260+ line_num: usize,
261+ ctx: &ParseContext,
262+) -> Result<Element, ParseError> {
263+ let (kind, rest) = if content.starts_with('[') {
264+ // Implicit @el
265+ (ElementKind::El, content.to_string())
266+ } else if content.starts_with('@') {
267+ let without_at = &content[1..];
268+ match without_at.find(|c: char| c == ' ' || c == '[') {
269+ Some(i) => {
270+ let kind_str = &without_at[..i];
271+ let rest = if without_at.as_bytes()[i] == b'[' {
272+ without_at[i..].to_string()
273+ } else {
274+ without_at[i + 1..].to_string()
275+ };
276+ (parse_element_kind(kind_str, line_num)?, rest)
277+ }
278+ None => (parse_element_kind(without_at, line_num)?, String::new()),
279+ }
280+ } else {
281+ return Err(ParseError {
282+ line: line_num,
283+ message: format!("expected @element or [attrs], got: {}", content),
284+ });
285+ };
286+
287+ // Parse optional [attrs]
288+ let (attrs, rest) = if rest.starts_with('[') {
289+ parse_attr_brackets(&rest, line_num, ctx)?
290+ } else {
291+ (Vec::new(), rest)
292+ };
293+
294+ let rest = rest.trim().to_string();
295+
296+ // For @link, first token of rest is URL, remainder is inline text
297+ let mut children = Vec::new();
298+ let argument = if rest.is_empty() {
299+ None
300+ } else if kind == ElementKind::Link {
301+ let rest_sub = substitute_vars(&rest, &ctx.variables);
302+ if let Some((url, text)) = rest_sub.split_once(' ') {
303+ let text = text.trim();
304+ if !text.is_empty() {
305+ children.push(Node::Text(parse_text_segments(text, ctx)));
306+ }
307+ Some(url.to_string())
308+ } else {
309+ Some(rest_sub)
310+ }
311+ } else {
312+ Some(substitute_vars(&rest, &ctx.variables))
313+ };
314+
315+ Ok(Element {
316+ kind,
317+ attrs,
318+ argument,
319+ children,
320+ })
321+}
322+
323+fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
324+ match s {
325+ "row" => Ok(ElementKind::Row),
326+ "column" | "col" => Ok(ElementKind::Column),
327+ "el" => Ok(ElementKind::El),
328+ "text" => Ok(ElementKind::Text),
329+ "paragraph" | "p" => Ok(ElementKind::Paragraph),
330+ "image" | "img" => Ok(ElementKind::Image),
331+ "link" => Ok(ElementKind::Link),
332+ _ => Err(ParseError {
333+ line: line_num,
334+ message: format!("unknown element @{}", s),
335+ }),
336+ }
337+}
338+
339+// ---------------------------------------------------------------------------
340+// Attribute parsing
341+// ---------------------------------------------------------------------------
342+
343+fn parse_attr_brackets(
344+ input: &str,
345+ line_num: usize,
346+ ctx: &ParseContext,
347+) -> Result<(Vec<Attribute>, String), ParseError> {
348+ let mut depth = 0;
349+ let mut end_pos = 0;
350+
351+ for (i, c) in input.char_indices() {
352+ match c {
353+ '[' => depth += 1,
354+ ']' => {
355+ depth -= 1;
356+ if depth == 0 {
357+ end_pos = i;
358+ break;
359+ }
360+ }
361+ _ => {}
362+ }
363+ }
364+
365+ if depth != 0 {
366+ return Err(ParseError {
367+ line: line_num,
368+ message: "unclosed '[' in attribute list".to_string(),
369+ });
370+ }
371+
372+ let attrs_inner = &input[1..end_pos];
373+ let remaining = input[end_pos + 1..].trim().to_string();
374+ let attrs = parse_attr_list(attrs_inner, ctx);
375+
376+ Ok((attrs, remaining))
377+}
378+
379+fn parse_attr_list(input: &str, ctx: &ParseContext) -> Vec<Attribute> {
380+ let mut attrs = Vec::new();
381+
382+ for part in split_commas(input) {
383+ let part = part.trim();
384+ if part.is_empty() {
385+ continue;
386+ }
387+
388+ // $define reference — expand
389+ if part.starts_with('$') {
390+ let name = &part[1..];
391+ if let Some(define_attrs) = ctx.defines.get(name) {
392+ attrs.extend(define_attrs.clone());
393+ continue;
394+ }
395+ }
396+
397+ // Substitute variables in value
398+ let part = substitute_vars(part, &ctx.variables);
399+
400+ if let Some((key, value)) = part.split_once(' ') {
401+ attrs.push(Attribute {
402+ key: key.trim().to_string(),
403+ value: Some(value.trim().to_string()),
404+ });
405+ } else {
406+ attrs.push(Attribute {
407+ key: part.to_string(),
408+ value: None,
409+ });
410+ }
411+ }
412+
413+ attrs
414+}
415+
416+fn split_commas(input: &str) -> Vec<&str> {
417+ let mut parts = Vec::new();
418+ let mut start = 0;
419+ let mut depth = 0;
420+
421+ for (i, c) in input.char_indices() {
422+ match c {
423+ '[' => depth += 1,
424+ ']' => depth -= 1,
425+ ',' if depth == 0 => {
426+ parts.push(&input[start..i]);
427+ start = i + 1;
428+ }
429+ _ => {}
430+ }
431+ }
432+ parts.push(&input[start..]);
433+ parts
434+}
435+
436+// ---------------------------------------------------------------------------
437+// Chain splitting (the > operator)
438+// ---------------------------------------------------------------------------
439+
440+fn split_chain(content: &str) -> Vec<String> {
441+ let mut segments = Vec::new();
442+ let mut current = String::new();
443+ let chars: Vec<char> = content.chars().collect();
444+ let mut i = 0;
445+ let mut bracket_depth = 0;
446+
447+ while i < chars.len() {
448+ if chars[i] == '[' {
449+ bracket_depth += 1;
450+ }
451+ if chars[i] == ']' {
452+ bracket_depth -= 1;
453+ }
454+
455+ // Match " > @" outside brackets
456+ if bracket_depth == 0
457+ && i + 3 < chars.len()
458+ && chars[i] == ' '
459+ && chars[i + 1] == '>'
460+ && chars[i + 2] == ' '
461+ && chars[i + 3] == '@'
462+ {
463+ segments.push(current.trim().to_string());
464+ current = String::new();
465+ i += 3; // skip " > ", keep the "@"
466+ continue;
467+ }
468+
469+ current.push(chars[i]);
470+ i += 1;
471+ }
472+
473+ if !current.trim().is_empty() {
474+ segments.push(current.trim().to_string());
475+ }
476+
477+ segments
478+}
479+
480+// ---------------------------------------------------------------------------
481+// Text segment parsing (inline {...} elements)
482+// ---------------------------------------------------------------------------
483+
484+fn parse_text_segments(input: &str, ctx: &ParseContext) -> Vec<TextSegment> {
485+ let mut segments = Vec::new();
486+ let mut current_text = String::new();
487+ let chars: Vec<char> = input.chars().collect();
488+ let mut i = 0;
489+
490+ while i < chars.len() {
491+ if chars[i] == '{'
492+ && i + 1 < chars.len()
493+ && chars[i + 1] == '@'
494+ {
495+ // Flush accumulated plain text
496+ if !current_text.is_empty() {
497+ segments.push(TextSegment::Plain(substitute_vars(
498+ &current_text,
499+ &ctx.variables,
500+ )));
501+ current_text.clear();
502+ }
503+
504+ // Find matching }
505+ let mut depth = 0;
506+ let start = i + 1; // after {
507+ i += 1;
508+ loop {
509+ if i >= chars.len() {
510+ break;
511+ }
512+ if chars[i] == '{' {
513+ depth += 1;
514+ } else if chars[i] == '}' {
515+ if depth == 0 {
516+ break;
517+ }
518+ depth -= 1;
519+ }
520+ i += 1;
521+ }
522+
523+ let inner: String = chars[start..i].iter().collect();
524+ i += 1; // skip }
525+
526+ if let Ok(elem) = parse_single_element(&inner, 0, ctx) {
527+ segments.push(TextSegment::Inline(elem));
528+ } else {
529+ current_text.push('{');
530+ current_text.push_str(&inner);
531+ current_text.push('}');
532+ }
533+ } else {
534+ current_text.push(chars[i]);
535+ i += 1;
536+ }
537+ }
538+
539+ if !current_text.is_empty() {
540+ segments.push(TextSegment::Plain(substitute_vars(
541+ &current_text,
542+ &ctx.variables,
543+ )));
544+ }
545+
546+ segments
547+}
548+
549+// ---------------------------------------------------------------------------
550+// Variable substitution
551+// ---------------------------------------------------------------------------
552+
553+fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
554+ if !input.contains('$') {
555+ return input.to_string();
556+ }
557+
558+ let mut result = String::new();
559+ let chars: Vec<char> = input.chars().collect();
560+ let mut i = 0;
561+
562+ while i < chars.len() {
563+ if chars[i] == '$' && i + 1 < chars.len() && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
564+ {
565+ let start = i + 1;
566+ let mut end = start;
567+ while end < chars.len()
568+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_')
569+ {
570+ end += 1;
571+ }
572+ let name: String = chars[start..end].iter().collect();
573+ if let Some(value) = vars.get(&name) {
574+ result.push_str(value);
575+ } else {
576+ result.push('$');
577+ result.push_str(&name);
578+ }
579+ i = end;
580+ } else {
581+ result.push(chars[i]);
582+ i += 1;
583+ }
584+ }
585+
586+ result
587+}