on master help sign in
Separate parsing from evaluation

syntax.rs turns source into a tree (indentation, continuation lines, verbatim blocks, multi-line strings, @if/@else chains, @each/@else, function definitions) without evaluating anything; parser.rs walks it. Block directives no longer re-collect their indented lines and start a sub-parser. An inline element's attribute list can now continue onto the next line (`Press {@kbd [` in the demo).

commit35ae18ab56fc2f5fc969355cca6fee5862a748f8 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:45 (2 weeks ago) parent35b1774
5 files changed+787−710
crates/htmlang-core/src/lib.rs+1 crates/htmlang-core/src/parser.rs+249−708 crates/htmlang-core/src/syntax.rs+517 examples/demo.html+2−2 tests/regressions.rs+18
crates/htmlang-core/src/lib.rs+1
@@ -2,6 +2,7 @@ pub mod ast;
22pub mod codegen;
33pub mod expr;
44pub mod parser;
5+mod syntax;
56pub mod vocab;
67
78#[cfg(test)]
crates/htmlang-core/src/parser.rs+249−708
@@ -3,6 +3,7 @@ use std::fmt;
33use std::path::{Path, PathBuf};
44
55use crate::ast::*;
6+use crate::syntax::{self, Syntax};
67
78// ---------------------------------------------------------------------------
89// Public types
@@ -47,24 +48,11 @@ impl fmt::Display for ParseError {
4748// Internal types
4849// ---------------------------------------------------------------------------
4950
50−#[derive(Clone)]
51−enum LineContent {
52− Normal(String),
53− Raw(String),
54−}
55−
56−#[derive(Clone)]
57−struct Line {
58− indent: usize,
59− content: LineContent,
60− line_num: usize,
61−}
62−
6351#[derive(Clone)]
6452struct FnDef {
6553 params: Vec<String>,
6654 defaults: HashMap<String, String>,
67− body_lines: Vec<Line>,
55+ body: Vec<Syntax>,
6856}
6957
7058struct ParseContext {
@@ -111,10 +99,8 @@ struct ParseContext {
11199 import_stack: Vec<PathBuf>,
112100}
113101
114−struct Parser {
115− lines: Vec<Line>,
116− pos: usize,
117−}
102+/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
103+struct Evaluator;
118104
119105impl ParseContext {
120106 /// Evaluate an expression (see `expr.rs`), reporting errors at `line`.
@@ -149,11 +135,7 @@ impl ParseContext {
149135const PRELUDE: &str = include_str!("std.hl");
150136
151137fn load_prelude(ctx: &mut ParseContext) {
152− let mut prelude = Parser {
153− lines: preprocess(PRELUDE),
154− pos: 0,
155− };
156− let _ = prelude.parse_children(0, ctx);
138+ let _ = Evaluator.eval_block(&syntax::parse(PRELUDE), ctx);
157139 // Library definitions aren't the file's own: never report them unused.
158140 ctx.fn_lines.clear();
159141 ctx.define_lines.clear();
@@ -165,8 +147,7 @@ pub fn parse(input: &str) -> ParseResult {
165147}
166148
167149pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
168− let lines = preprocess(input);
169− let mut parser = Parser { lines, pos: 0 };
150+ let tree = syntax::parse(input);
170151 let mut ctx = ParseContext {
171152 current_line: 0,
172153 scoped_functions: std::collections::HashSet::new(),
@@ -199,7 +180,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
199180 import_stack: Vec::new(),
200181 };
201182 load_prelude(&mut ctx);
202− let nodes = parser.parse_children(0, &mut ctx);
183+ let nodes = Evaluator.eval_block(&tree, &mut ctx);
203184 validate_tree(&nodes, None, &mut ctx.diagnostics);
204185 check_unused(&mut ctx);
205186 ParseResult {
@@ -260,232 +241,43 @@ fn collect_images_recursive(nodes: &[Node], hints: &mut Vec<crate::ast::PreloadH
260241// Preprocessing: strip comments/blanks, collapse @raw blocks
261242// ---------------------------------------------------------------------------
262243
263−fn preprocess(input: &str) -> Vec<Line> {
264− let raw_lines: Vec<&str> = input.lines().collect();
265− let mut lines = Vec::new();
266− let mut i = 0;
267−
268− while i < raw_lines.len() {
269− let line = raw_lines[i];
270− let trimmed = line.trim();
271−
272− if trimmed.is_empty() || trimmed.starts_with("--") {
273− i += 1;
274− continue;
275− }
276−
277− let indent = line.len() - line.trim_start().len();
278−
279− // Inline `@markdown` and `@script` blocks keep their bodies verbatim:
280− // in Markdown, blank lines separate paragraphs, `---` is a rule (not
281− // a comment) and code indentation matters; in JavaScript, newlines,
282− // `{...}` and `$` must reach the output untouched.
283− let verbatim_body = trimmed == "@markdown"
284− || trimmed == "@script"
285− || trimmed.starts_with("@script ")
286− || trimmed.starts_with("@script[");
287− if verbatim_body {
288− lines.push(Line {
289− indent,
290− content: LineContent::Normal(trimmed.to_string()),
291− line_num: i + 1,
292− });
293− let body_start = i + 1;
294− let mut body_end = body_start;
295− let mut j = body_start;
296− while j < raw_lines.len() {
297− let l = raw_lines[j];
298− if l.trim().is_empty() {
299− j += 1;
300− continue;
301− }
302− if l.len() - l.trim_start().len() <= indent {
303− break;
304− }
305− j += 1;
306− body_end = j;
307− }
308− let body = &raw_lines[body_start..body_end];
309− let body_indent = body
310− .iter()
311− .filter(|l| !l.trim().is_empty())
312− .map(|l| l.len() - l.trim_start().len())
313− .min()
314− .unwrap_or(0);
315− if !body.is_empty() {
316− let text: Vec<&str> = body
317− .iter()
318− .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
319− .collect();
320− lines.push(Line {
321− indent: indent + 1,
322− content: LineContent::Raw(text.join("\n")),
323− line_num: body_start + 1,
324− });
325− }
326− i = body_end.max(body_start);
327− continue;
328− }
329−
330− // Handle @raw """..."""
331− if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
332− let after_raw = raw_rest.trim_start();
333− if let Some(after_open) = after_raw.strip_prefix("\"\"\"") {
334− // Single-line: @raw """content"""
335− if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
336− let content = &after_open[..after_open.len() - 3];
337− lines.push(Line {
338− indent,
339− content: LineContent::Raw(content.to_string()),
340− line_num: i + 1,
341− });
342− i += 1;
343− continue;
344− }
345−
346− // Multiline: collect until closing """
347− let mut raw_content = String::new();
348− if !after_open.is_empty() {
349− raw_content.push_str(after_open);
350− raw_content.push('\n');
351− }
352− i += 1;
353− while i < raw_lines.len() {
354− if raw_lines[i].trim() == "\"\"\"" {
355− i += 1;
356− break;
357− }
358− raw_content.push_str(raw_lines[i]);
359− raw_content.push('\n');
360− i += 1;
361− }
362−
363− lines.push(Line {
364− indent,
365− content: LineContent::Raw(raw_content.trim_end_matches('\n').to_string()),
366− line_num: i,
367− });
368− continue;
369− }
370− }
371−
372− // Join continuation lines for multi-line attribute brackets
373− let first_line_num = i + 1;
374− let mut full = trimmed.to_string();
375− while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() {
376− i += 1;
377− let next = raw_lines[i].trim();
378− if next.is_empty() || next.starts_with("--") {
379− continue;
380− }
381− full.push(' ');
382− full.push_str(next);
383− }
384−
385− lines.push(Line {
386− indent,
387− content: LineContent::Normal(full),
388− line_num: first_line_num,
389− });
390− i += 1;
391− }
392−
393− lines
394−}
395−
396−/// Bracket depth left open at the end of `line`, counting only attribute
397−/// lists: a `[` that starts the line or follows an `@name` token (optionally
398−/// with one more word, as in `@let name [`). Brackets in text content, such
399−/// as `@text [bold] Use [ to open`, are ignored.
400−fn open_attr_depth(line: &str) -> i32 {
401− if !line.starts_with('@') && !line.starts_with('[') {
402− return 0;
403− }
404− let bytes = line.as_bytes();
405− let mut depth: i32 = 0;
406− for (i, &b) in bytes.iter().enumerate() {
407− match b {
408− b'[' if depth > 0 => depth += 1,
409− b'[' => {
410− // Is this the start of an attribute list?
411− let before = line[..i].trim_end();
412− let last_directive = before
413− .rsplit([']', '>'])
414− .next()
415− .unwrap_or("")
416− .trim();
417− let tokens: Vec<&str> = last_directive.split_whitespace().collect();
418− let starts_list = match tokens.as_slice() {
419− [] => before.is_empty() || before.ends_with('>'),
420− [name] => name.starts_with('@'),
421− [name, _] => name.starts_with('@'),
422− _ => false,
423− };
424− if starts_list {
425− depth = 1;
426− } else if before.ends_with(']') {
427− // Text after a closed attribute list: stop scanning.
428− return 0;
429− }
430− }
431− b']' if depth > 0 => depth -= 1,
432− _ => {}
433− }
434− }
435− depth
436−}
437−
438244// ---------------------------------------------------------------------------
439−// Parser
245+// Evaluation
440246// ---------------------------------------------------------------------------
441247
442−impl Parser {
443− fn parse_children(&mut self, min_indent: usize, ctx: &mut ParseContext) -> Vec<Node> {
248+impl Evaluator {
249+ fn eval_block(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
444250 let mut nodes = Vec::new();
445−
446− while self.pos < self.lines.len() {
447− let indent = self.lines[self.pos].indent;
448− if indent < min_indent {
449− break;
450− }
451−
452− match self.parse_line(ctx) {
251+ for node in block {
252+ match self.eval(node, ctx) {
453253 Ok(Some(new_nodes)) => nodes.extend(new_nodes),
454254 Ok(None) => {}
455− Err(e) => {
456− // Error recovery: record the diagnostic and continue parsing
457− // to report multiple errors in a single pass
458− let source = if self.pos > 0 && self.pos <= self.lines.len() {
459− match &self.lines[self.pos.saturating_sub(1)].content {
460− LineContent::Normal(s) => Some(s.clone()),
461− LineContent::Raw(s) => Some(s.clone()),
462− }
463− } else {
464− None
465− };
466− ctx.diagnostics.push(Diagnostic {
467− line: e.line,
468− column: None,
469− message: e.message,
470− severity: Severity::Error,
471− source_line: source,
472− });
473− // Skip forward past any deeper-indented children of the errored line
474− while self.pos < self.lines.len() && self.lines[self.pos].indent > indent {
475− self.pos += 1;
476− }
477− }
255+ // Record the error and go on, to report several in one pass
256+ Err(e) => ctx.diagnostics.push(Diagnostic {
257+ line: e.line,
258+ column: None,
259+ message: e.message,
260+ severity: Severity::Error,
261+ source_line: Some(node.source()),
262+ }),
478263 }
479264 }
265+ nodes
266+ }
480267
268+ /// Evaluate a block in its own scope: `@let` inside doesn't leak out.
269+ fn eval_scoped(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
270+ let saved_vars = ctx.variables.clone();
271+ let nodes = self.eval_block(block, ctx);
272+ ctx.variables = saved_vars;
481273 nodes
482274 }
483275
484− fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
485− let line_num = self.lines[self.pos].line_num;
276+ fn eval(&mut self, node: &Syntax, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
277+ let line_num = node.line();
486278 ctx.current_line = line_num;
487− if let LineContent::Normal(text) = &self.lines[self.pos].content
488− && let Some(filter) = old_filter_syntax(text)
279+ if !matches!(node, Syntax::Raw { .. })
280+ && let Some(filter) = old_filter_syntax(&node.source())
489281 {
490282 ctx.diagnostics.push(Diagnostic {
491283 line: line_num,
@@ -495,29 +287,35 @@ impl Parser {
495287 filter.0, filter.1, filter.1, filter.0
496288 ),
497289 severity: Severity::Warning,
498− source_line: Some(text.clone()),
290+ source_line: Some(node.source()),
499291 });
500292 }
501− let current_indent = self.lines[self.pos].indent;
502−
503− // Handle raw content
504− if let LineContent::Raw(s) = &self.lines[self.pos].content {
505− let content = s.clone();
506− self.pos += 1;
507− return Ok(Some(vec![Node::Raw(content)]));
508− }
509−
510− // Normal content — clone to release borrow. Raw is already handled
511− // above; a let-else pattern avoids a panic path if new LineContent
512− // variants are added in the future.
513− let LineContent::Normal(content) = &self.lines[self.pos].content else {
514− return Err(ParseError {
515− line: line_num,
516− message: "internal: unexpected line content variant".to_string(),
517− });
293+ let (content, current_indent, children) = match node {
294+ Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
295+ Syntax::Function { name, params, defaults, body, .. } => {
296+ self.define_function(name, params, defaults, body, line_num, ctx);
297+ return Ok(None);
298+ }
299+ Syntax::If { branches } => {
300+ // Every condition is checked (and its errors reported);
301+ // the first that holds picks the branch.
302+ let mut chosen = None;
303+ for branch in branches {
304+ let holds = match &branch.condition {
305+ Some(condition) => ctx.condition(condition, branch.line),
306+ None => true,
307+ };
308+ if holds && chosen.is_none() {
309+ chosen = Some(&branch.body);
310+ }
311+ }
312+ return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
313+ }
314+ Syntax::Each { header, body, empty, .. } => {
315+ return self.eval_each(header, body, empty, line_num, ctx).map(Some);
316+ }
317+ Syntax::Line { text, indent, children, .. } => (text.clone(), *indent, children),
518318 };
519− let content = content.clone();
520− self.pos += 1;
521319
522320 // --- Directives ---
523321
@@ -569,76 +367,11 @@ impl Parser {
569367 if let Some(rest) = content.strip_prefix("@let ") {
570368 let rest = rest.trim();
571369
572− // A `"""` value opens a multi-line string, whose indented lines
573− // are its content — not a function body.
574− let opens_triple_quote = rest.split_once(' ').is_some_and(|(_, v)| {
575− let v = v.trim();
576− v.strip_prefix("= ").unwrap_or(v).trim_start().starts_with("\"\"\"")
577− });
578− // Check if next lines are indented (function/component definition)
579− let has_body = !opens_triple_quote
580− && self.pos < self.lines.len()
581− && self.lines[self.pos].indent > current_indent;
582−
583− if has_body {
584− // Function definition: @let name $param1 $param2=default
585− let parts: Vec<&str> = rest.split_whitespace().collect();
586− if parts.is_empty() {
587− return Err(ParseError {
588− line: line_num,
589− message: "@let with body requires a name".to_string(),
590− });
591− }
592− let name = parts[0].to_string();
593− let mut params = Vec::new();
594− let mut defaults = HashMap::new();
595− for part in &parts[1..] {
596− let part = part.strip_prefix('$').unwrap_or(part);
597− if let Some((param_name, default_val)) = part.split_once('=') {
598− params.push(param_name.to_string());
599− defaults.insert(param_name.to_string(), default_val.to_string());
600− } else {
601− params.push(part.to_string());
602− }
603− }
604−
605− // Collect body lines (all lines indented deeper than @let)
606− let mut body_lines = Vec::new();
607− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
608− body_lines.push(self.lines[self.pos].clone());
609− self.pos += 1;
610− }
611−
612− // An @style block at the top of the body is scoped to the
613− // function: its rules apply inside a `.hl-NAME` wrapper.
614− let (body_lines, style_lines) = split_style_block(body_lines);
615− if !style_lines.is_empty() {
616− let scope_class = format!("hl-{}", name);
617− let scoped_css: String = style_lines
618− .iter()
619− .filter_map(|line| match &line.content {
620− LineContent::Normal(s) if !s.trim().is_empty() => {
621− Some(format!(".{} {}\n", scope_class, s.trim()))
622− }
623− _ => None,
624− })
625− .collect();
626− if !scoped_css.is_empty() {
627− ctx.custom_css.push(scoped_css);
628− }
629− ctx.scoped_functions.insert(name.clone());
630− }
631−
632− ctx.fn_lines.entry(name.clone()).or_insert(line_num);
633− ctx.functions.insert(
634− name,
635− FnDef {
636− params,
637− defaults,
638− body_lines,
639− },
640− );
641− return Ok(None);
370+ if !children.is_empty() {
371+ return Err(ParseError {
372+ line: line_num,
373+ message: "@let with body requires a name".to_string(),
374+ });
642375 }
643376
644377 if let Some((name, value)) = rest.split_once(' ') {
@@ -666,40 +399,10 @@ impl Parser {
666399 return Ok(None);
667400 }
668401
669− // Multi-line @let with triple quotes: @let name """..."""
670− let value_str;
402+ // Triple quotes: @let name """...""" (syntax.rs joins the
403+ // lines of a multi-line string)
671404 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
672− if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
673− // Single-line triple-quote: @let name """value"""
674− value_str = after_open[..after_open.len() - 3].to_string();
675− value_str.as_str()
676− } else {
677− // Multi-line: collect indented body lines until closing """
678− let mut lines_buf = String::new();
679− if !after_open.is_empty() {
680− lines_buf.push_str(after_open);
681− lines_buf.push('\n');
682− }
683− while self.pos < self.lines.len() {
684− match &self.lines[self.pos].content {
685− LineContent::Normal(s) if s.trim() == "\"\"\"" => {
686− self.pos += 1;
687− break;
688− }
689− LineContent::Normal(s) => {
690− lines_buf.push_str(s);
691− lines_buf.push('\n');
692− }
693− LineContent::Raw(s) => {
694− lines_buf.push_str(s);
695− lines_buf.push('\n');
696− }
697− }
698− self.pos += 1;
699− }
700− value_str = lines_buf.trim_end_matches('\n').to_string();
701− value_str.as_str()
702− }
405+ after_open.strip_suffix("\"\"\"").unwrap_or(after_open)
703406 } else if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
704407 // Support quoted string interpolation: @let greeting "Hello $name"
705408 &value[1..value.len() - 1]
@@ -733,22 +436,7 @@ impl Parser {
733436
734437
735438 if content == "@head" || content.starts_with("@head ") {
736− // Collect indented body lines as raw head content
737− let mut head_content = String::new();
738− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
739− match &self.lines[self.pos].content {
740− LineContent::Normal(s) => {
741− head_content.push_str(s.trim());
742− head_content.push('\n');
743− }
744− LineContent::Raw(s) => {
745− head_content.push_str(s);
746− head_content.push('\n');
747− }
748− }
749− self.pos += 1;
750− }
751− let trimmed = head_content.trim().to_string();
439+ let trimmed = block_text(children).trim().to_string();
752440 if !trimmed.is_empty() {
753441 ctx.head_blocks.push(trimmed);
754442 }
@@ -757,21 +445,7 @@ impl Parser {
757445
758446 // --- @style block (raw CSS) ---
759447 if content.trim() == "@style" {
760− let mut style_content = String::new();
761− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
762− match &self.lines[self.pos].content {
763− LineContent::Normal(s) => {
764− style_content.push_str(s.trim());
765− style_content.push('\n');
766− }
767− LineContent::Raw(s) => {
768− style_content.push_str(s);
769− style_content.push('\n');
770− }
771− }
772− self.pos += 1;
773− }
774− let trimmed = style_content.trim().to_string();
448+ let trimmed = block_text(children).trim().to_string();
775449 if !trimmed.is_empty() {
776450 ctx.custom_css.push(trimmed);
777451 }
@@ -784,14 +458,7 @@ impl Parser {
784458 let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
785459 if arg.is_empty() {
786460 // Inline markdown block: indented children
787− let mut md_lines = Vec::new();
788− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
789− match &self.lines[self.pos].content {
790− LineContent::Normal(s) => md_lines.push(s.clone()),
791− LineContent::Raw(s) => md_lines.extend(s.lines().map(String::from)),
792− }
793− self.pos += 1;
794− }
461+ let md_lines: Vec<String> = block_text(children).lines().map(String::from).collect();
795462 let html = markdown_to_html(&md_lines);
796463 return Ok(Some(vec![Node::Raw(html)]));
797464 } else {
@@ -940,12 +607,7 @@ impl Parser {
940607 }
941608 None => format!("@include \"{}\"", rel_name),
942609 };
943− let synth_lines = preprocess(&import_line);
944− let mut synth_parser = Parser {
945− lines: synth_lines,
946− pos: 0,
947− };
948− matched_nodes.extend(synth_parser.parse_children(0, ctx));
610+ matched_nodes.extend(self.eval_block(&syntax::parse(&import_line), ctx));
949611 }
950612 return Ok(Some(matched_nodes));
951613 }
@@ -1011,12 +673,7 @@ impl Parser {
1011673 let var_keys_before: std::collections::HashSet<String> =
1012674 ctx.variables.keys().cloned().collect();
1013675
1014− let imported_lines = preprocess(&imported_text);
1015− let mut imported_parser = Parser {
1016− lines: imported_lines,
1017− pos: 0,
1018− };
1019− let _discarded_nodes = imported_parser.parse_children(0, ctx);
676+ let _discarded_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
1020677
1021678 // Remove newly added entries and re-insert them under the prefix.
1022679 let new_fn_keys: Vec<String> = ctx
@@ -1055,12 +712,7 @@ impl Parser {
1055712 }
1056713 }
1057714 } else {
1058− let included_lines = preprocess(&imported_text);
1059− let mut included_parser = Parser {
1060− lines: included_lines,
1061− pos: 0,
1062− };
1063− included_nodes = included_parser.parse_children(0, ctx);
715+ included_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
1064716 }
1065717
1066718 // Annotate new diagnostics with import chain
@@ -1210,91 +862,6 @@ impl Parser {
1210862
1211863 // --- @if / @else ---
1212864
1213− if let Some(rest) = content.strip_prefix("@if ") {
1214− let result = ctx.condition(rest.trim(), line_num);
1215−
1216− // Collect then-body lines
1217− let mut then_lines = Vec::new();
1218− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1219− then_lines.push(self.lines[self.pos].clone());
1220− self.pos += 1;
1221− }
1222−
1223− // Build branches: [(condition_result, body_lines), ...]
1224− let mut branches: Vec<(bool, Vec<Line>)> = vec![(result, then_lines)];
1225−
1226− // Check for @else if / @else chains at same indent
1227− loop {
1228− if self.pos >= self.lines.len() || self.lines[self.pos].indent != current_indent {
1229− break;
1230− }
1231− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1232− let trimmed = s.trim();
1233− if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
1234− self.pos += 1; // consume @else if
1235− let cond_result =
1236− ctx.condition(else_if_cond.trim(), self.lines[self.pos - 1].line_num);
1237− let mut body = Vec::new();
1238− while self.pos < self.lines.len()
1239− && self.lines[self.pos].indent > current_indent
1240− {
1241− body.push(self.lines[self.pos].clone());
1242− self.pos += 1;
1243− }
1244− branches.push((cond_result, body));
1245− } else if trimmed == "@else" {
1246− self.pos += 1; // consume @else
1247− let mut body = Vec::new();
1248− while self.pos < self.lines.len()
1249− && self.lines[self.pos].indent > current_indent
1250− {
1251− body.push(self.lines[self.pos].clone());
1252− self.pos += 1;
1253− }
1254− // @else is always true (fallback)
1255− branches.push((true, body));
1256− break;
1257− } else {
1258− break;
1259− }
1260− } else {
1261− break;
1262− }
1263− }
1264−
1265− // Pick the first branch whose condition is true
1266− let body_lines = branches
1267− .into_iter()
1268− .find(|(cond, _)| *cond)
1269− .map(|(_, lines)| lines)
1270− .unwrap_or_default();
1271−
1272− if body_lines.is_empty() {
1273− return Ok(None);
1274− }
1275−
1276− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1277− let adjusted: Vec<Line> = body_lines
1278− .iter()
1279− .map(|l| Line {
1280− indent: l.indent - min_indent,
1281− content: l.content.clone(),
1282− line_num: l.line_num,
1283− })
1284− .collect();
1285−
1286− // Scope variables: @let inside @if doesn't leak out
1287− let saved_vars = ctx.variables.clone();
1288− let mut body_parser = Parser {
1289− lines: adjusted,
1290− pos: 0,
1291− };
1292− let nodes = body_parser.parse_children(0, ctx);
1293− ctx.variables = saved_vars;
1294− return Ok(Some(nodes));
1295− }
1296−
1297−
1298865 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
1299866 return Err(ParseError {
1300867 line: line_num,
@@ -1304,157 +871,6 @@ impl Parser {
1304871
1305872 // --- @each loop ---
1306873
1307− if let Some(rest) = content.strip_prefix("@each ") {
1308− let rest = rest.trim();
1309− // Support: @each $var in list OR @each $var, $index in list
1310− // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
1311− let (var_names, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ")
1312− {
1313− let before_in = before_in.trim();
1314− let vars: Vec<String> = before_in
1315− .split(',')
1316− .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
1317− .collect();
1318− (vars, after_in.trim().to_string())
1319− } else {
1320− return Err(ParseError {
1321− line: line_num,
1322− message: "@each requires: @each $var in list".to_string(),
1323− });
1324− };
1325− let var_name = var_names[0].clone();
1326− let index_var = var_names.get(1).cloned();
1327−
1328− if list_str.trim_end().ends_with(']') && list_str.contains("[page ") {
1329− return Err(ParseError {
1330− line: line_num,
1331− message: "@each pagination (`[page N]`) was removed: split the list or \
1332− filter it with @if"
1333− .to_string(),
1334− });
1335− }
1336− track_var_refs(&list_str, &mut ctx.used_variables);
1337− let list_str = substitute_vars(&list_str, &ctx.variables);
1338− // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
1339− let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
1340− let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
1341− (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
1342− } else {
1343− (rest.trim(), 1i64)
1344− };
1345− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1346− {
1347− numeric_range(start, end, step)
1348− } else {
1349− list_str
1350− .split(',')
1351− .map(|s| s.trim().to_string())
1352− .filter(|s| !s.is_empty())
1353− .collect()
1354− }
1355− } else {
1356− list_str
1357− .split(',')
1358− .map(|s| s.trim().to_string())
1359− .filter(|s| !s.is_empty())
1360− .collect()
1361− };
1362−
1363− // Collect body lines
1364− let mut body_lines = Vec::new();
1365− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1366− body_lines.push(self.lines[self.pos].clone());
1367− self.pos += 1;
1368− }
1369−
1370− // Check for @else block (empty-state fallback)
1371− let mut else_lines = Vec::new();
1372− if self.pos < self.lines.len()
1373− && self.lines[self.pos].indent == current_indent
1374− && let LineContent::Normal(ref s) = self.lines[self.pos].content
1375− && s.trim() == "@else"
1376− {
1377− self.pos += 1; // consume @else
1378− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1379− else_lines.push(self.lines[self.pos].clone());
1380− self.pos += 1;
1381− }
1382− }
1383−
1384− if body_lines.is_empty() {
1385− return Ok(None);
1386− }
1387−
1388− // If list is empty, render @else body
1389− if items.is_empty() {
1390− if else_lines.is_empty() {
1391− return Ok(None);
1392− }
1393− let min_indent = else_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1394− let adjusted: Vec<Line> = else_lines
1395− .iter()
1396− .map(|l| Line {
1397− indent: l.indent - min_indent,
1398− content: l.content.clone(),
1399− line_num: l.line_num,
1400− })
1401− .collect();
1402− let mut body_parser = Parser {
1403− lines: adjusted,
1404− pos: 0,
1405− };
1406− let saved_vars = ctx.variables.clone();
1407− let nodes = body_parser.parse_children(0, ctx);
1408− ctx.variables = saved_vars;
1409− return Ok(Some(nodes));
1410− }
1411−
1412− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1413− let adjusted: Vec<Line> = body_lines
1414− .iter()
1415− .map(|l| Line {
1416− indent: l.indent - min_indent,
1417− content: l.content.clone(),
1418− line_num: l.line_num,
1419− })
1420− .collect();
1421−
1422− let saved_vars = ctx.variables.clone();
1423− let mut all_nodes = Vec::new();
1424−
1425− let has_extra_vars = var_names.len() > 2
1426− || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
1427−
1428− for (i, item) in items.iter().enumerate() {
1429− // Always expose $_index for the current iteration
1430− ctx.variables.insert("_index".to_string(), i.to_string());
1431− if has_extra_vars {
1432− // Destructuring: split item by spaces and assign to each variable
1433− let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
1434− for (vi, vn) in var_names.iter().enumerate() {
1435− let val = parts.get(vi).unwrap_or(&"").to_string();
1436− ctx.variables.insert(vn.clone(), val);
1437− }
1438− } else {
1439− ctx.variables.insert(var_name.clone(), item.clone());
1440− if let Some(ref idx_name) = index_var {
1441− ctx.variables.insert(idx_name.clone(), i.to_string());
1442− }
1443− }
1444− let mut body_parser = Parser {
1445− lines: adjusted.clone(),
1446− pos: 0,
1447− };
1448− let nodes = body_parser.parse_children(0, ctx);
1449− all_nodes.extend(nodes);
1450− }
1451−
1452− ctx.variables = saved_vars;
1453− return Ok(Some(all_nodes));
1454− }
1455−
1456−
1457−
1458874 // --- @warn / @debug ---
1459875
1460876 if let Some(rest) = content.strip_prefix("@warn ") {
@@ -1480,11 +896,10 @@ impl Parser {
1480896 message: "@keyframes requires a name".to_string(),
1481897 });
1482898 }
1483− // Collect body lines (indented deeper)
1484899 let mut body = String::new();
1485− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1486− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1487− let trimmed = s.trim();
900+ for_each_line(children, &mut |node| {
901+ if let Syntax::Line { text, .. } = node {
902+ let trimmed = text.trim();
1488903 // Support htmlang-style: from [opacity 0] / to [opacity 1] / 50% [transform scale(1.5)]
1489904 if let Some(kf_css) = parse_keyframe_line(trimmed) {
1490905 body.push_str(&kf_css);
@@ -1492,8 +907,7 @@ impl Parser {
1492907 body.push_str(trimmed);
1493908 }
1494909 }
1495− self.pos += 1;
1496− }
910+ });
1497911 ctx.keyframes.push((name, body));
1498912 return Ok(None);
1499913 }
@@ -1506,7 +920,7 @@ impl Parser {
1506920 let name = extract_element_name(&content);
1507921 if ctx.functions.contains_key(name) {
1508922 ctx.used_functions.insert(name.to_string());
1509− let nodes = self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
923+ let nodes = self.expand_fn_call(name, &content, children, line_num, ctx)?;
1510924 return Ok(Some(nodes));
1511925 }
1512926 }
@@ -1514,7 +928,7 @@ impl Parser {
1514928 // --- Elements ---
1515929
1516930 if content.starts_with('@') || content.starts_with('[') {
1517− let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
931+ let node = self.parse_element_line(&content, children, line_num, ctx)?;
1518932 return Ok(Some(vec![inline_svg(node, line_num, ctx)]));
1519933 }
1520934
@@ -1612,11 +1026,153 @@ impl Parser {
16121026 ctx.variables.insert(name.to_string(), stems.join(", "));
16131027 }
16141028
1029+ fn eval_each(
1030+ &mut self,
1031+ header: &str,
1032+ body: &[Syntax],
1033+ empty: &[Syntax],
1034+ line_num: usize,
1035+ ctx: &mut ParseContext,
1036+ ) -> Result<Vec<Node>, ParseError> {
1037+ // Support: @each $var in list OR @each $var, $index in list
1038+ // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
1039+ let (var_names, list_str) = if let Some((before_in, after_in)) = header.split_once(" in ") {
1040+ let before_in = before_in.trim();
1041+ let vars: Vec<String> = before_in
1042+ .split(',')
1043+ .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
1044+ .collect();
1045+ (vars, after_in.trim().to_string())
1046+ } else {
1047+ return Err(ParseError {
1048+ line: line_num,
1049+ message: "@each requires: @each $var in list".to_string(),
1050+ });
1051+ };
1052+ let var_name = var_names[0].clone();
1053+ let index_var = var_names.get(1).cloned();
1054+
1055+ if list_str.trim_end().ends_with(']') && list_str.contains("[page ") {
1056+ return Err(ParseError {
1057+ line: line_num,
1058+ message: "@each pagination (`[page N]`) was removed: split the list or \
1059+ filter it with @if"
1060+ .to_string(),
1061+ });
1062+ }
1063+ track_var_refs(&list_str, &mut ctx.used_variables);
1064+ let list_str = substitute_vars(&list_str, &ctx.variables);
1065+ // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
1066+ let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
1067+ let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
1068+ (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
1069+ } else {
1070+ (rest.trim(), 1i64)
1071+ };
1072+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1073+ {
1074+ numeric_range(start, end, step)
1075+ } else {
1076+ list_str
1077+ .split(',')
1078+ .map(|s| s.trim().to_string())
1079+ .filter(|s| !s.is_empty())
1080+ .collect()
1081+ }
1082+ } else {
1083+ list_str
1084+ .split(',')
1085+ .map(|s| s.trim().to_string())
1086+ .filter(|s| !s.is_empty())
1087+ .collect()
1088+ };
1089+
1090+ if body.is_empty() {
1091+ return Ok(Vec::new());
1092+ }
1093+ // An empty list renders the @else body
1094+ if items.is_empty() {
1095+ return Ok(self.eval_scoped(empty, ctx));
1096+ }
1097+
1098+ let saved_vars = ctx.variables.clone();
1099+ let mut all_nodes = Vec::new();
1100+
1101+ let has_extra_vars = var_names.len() > 2
1102+ || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
1103+
1104+ for (i, item) in items.iter().enumerate() {
1105+ // Always expose $_index for the current iteration
1106+ ctx.variables.insert("_index".to_string(), i.to_string());
1107+ if has_extra_vars {
1108+ // Destructuring: split item by spaces and assign to each variable
1109+ let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
1110+ for (vi, vn) in var_names.iter().enumerate() {
1111+ let val = parts.get(vi).unwrap_or(&"").to_string();
1112+ ctx.variables.insert(vn.clone(), val);
1113+ }
1114+ } else {
1115+ ctx.variables.insert(var_name.clone(), item.clone());
1116+ if let Some(ref idx_name) = index_var {
1117+ ctx.variables.insert(idx_name.clone(), i.to_string());
1118+ }
1119+ }
1120+ all_nodes.extend(self.eval_block(body, ctx));
1121+ }
1122+
1123+ ctx.variables = saved_vars;
1124+ Ok(all_nodes)
1125+ }
1126+
1127+ fn define_function(
1128+ &mut self,
1129+ name: &str,
1130+ params: &[String],
1131+ defaults: &HashMap<String, String>,
1132+ body: &[Syntax],
1133+ line_num: usize,
1134+ ctx: &mut ParseContext,
1135+ ) {
1136+ // An @style block at the top of the body is scoped to the
1137+ // function: its rules apply inside a `.hl-NAME` wrapper.
1138+ let (style, body): (Vec<&Syntax>, Vec<&Syntax>) = body
1139+ .iter()
1140+ .partition(|node| matches!(node, Syntax::Line { text, .. } if text == "@style"));
1141+ if !style.is_empty() {
1142+ let mut scoped_css = String::new();
1143+ for node in style {
1144+ if let Syntax::Line { children, .. } = node {
1145+ for_each_line(children, &mut |node| {
1146+ if let Syntax::Line { text, .. } = node
1147+ && !text.trim().is_empty()
1148+ {
1149+ scoped_css.push_str(&format!(".hl-{} {}\n", name, text.trim()));
1150+ }
1151+ });
1152+ }
1153+ }
1154+ if !scoped_css.is_empty() {
1155+ ctx.custom_css.push(scoped_css);
1156+ }
1157+ ctx.scoped_functions.insert(name.to_string());
1158+ }
1159+
1160+ ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
1161+ ctx.functions.insert(
1162+ name.to_string(),
1163+ FnDef {
1164+ params: params.to_vec(),
1165+ defaults: defaults.clone(),
1166+ body: body.into_iter().cloned().collect(),
1167+ },
1168+ );
1169+ }
1170+
16151171 fn expand_fn_call(
16161172 &mut self,
16171173 name: &str,
16181174 content: &str,
1619− current_indent: usize,
1175+ children: &[Syntax],
16201176 line_num: usize,
16211177 ctx: &mut ParseContext,
16221178 ) -> Result<Vec<Node>, ParseError> {
@@ -1658,7 +1214,7 @@ impl Parser {
16581214 };
16591215
16601216 // Parse caller's children, separating named slots from default children
1661− let all_caller_children = self.parse_children(current_indent + 1, ctx);
1217+ let all_caller_children = self.eval_block(children, ctx);
16621218 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
16631219 let mut caller_children = Vec::new();
16641220 for child in all_caller_children {
@@ -1716,29 +1272,8 @@ impl Parser {
17161272 .map(|(a, _)| a.clone())
17171273 .collect();
17181274
1719− // Normalize body indentation so it parses from indent 0
1720− let min_indent = fn_def
1721− .body_lines
1722− .iter()
1723− .map(|l| l.indent)
1724− .min()
1725− .unwrap_or(0);
1726− let adjusted: Vec<Line> = fn_def
1727− .body_lines
1728− .iter()
1729− .map(|l| Line {
1730− indent: l.indent - min_indent,
1731− content: l.content.clone(),
1732− line_num: l.line_num,
1733− })
1734− .collect();
1735−
1736− // Parse body with params in scope
1737− let mut body_parser = Parser {
1738− lines: adjusted,
1739− pos: 0,
1740− };
1741− let body_nodes = body_parser.parse_children(0, ctx);
1275+ // Evaluate the body with the parameters in scope
1276+ let body_nodes = self.eval_block(&fn_def.body, ctx);
17421277
17431278 // Restore variables and call stack
17441279 ctx.variables = saved_vars;
@@ -1796,7 +1331,7 @@ impl Parser {
17961331 fn parse_element_line(
17971332 &mut self,
17981333 content: &str,
1799− current_indent: usize,
1334+ children: &[Syntax],
18001335 line_num: usize,
18011336 ctx: &mut ParseContext,
18021337 ) -> Result<Node, ParseError> {
@@ -1810,7 +1345,7 @@ impl Parser {
18101345 }
18111346
18121347 // Parse indented children (belong to the innermost element)
1813− let children = self.parse_children(current_indent + 1, ctx);
1348+ let children = self.eval_block(children, ctx);
18141349
18151350 // Build chain right-to-left: rightmost gets children, each wraps the next
18161351 let mut current_children = children;
@@ -3949,26 +3484,32 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
39493484 }
39503485}
39513486
3952−/// Split a function body into its content and an @style block at the
3953−/// body's top level (the block's CSS lines, without the `@style` line).
3954−fn split_style_block(body: Vec<Line>) -> (Vec<Line>, Vec<Line>) {
3955− let top = body.iter().map(|l| l.indent).min().unwrap_or(0);
3956− let mut content = Vec::new();
3957− let mut style = Vec::new();
3958− let mut in_style = false;
3959− for line in body {
3960− let is_style_header =
3961− line.indent == top && matches!(&line.content, LineContent::Normal(s) if s.trim() == "@style");
3962− if is_style_header {
3963− in_style = true;
3964− } else if in_style && line.indent > top {
3965− style.push(line);
3966− } else {
3967− in_style = false;
3968− content.push(line);
3487+/// Visit the lines of a block in source order, children after their line.
3488+fn for_each_line<'a>(block: &'a [Syntax], f: &mut impl FnMut(&'a Syntax)) {
3489+ for node in block {
3490+ f(node);
3491+ if let Syntax::Line { children, .. } = node {
3492+ for_each_line(children, f);
39693493 }
39703494 }
3971− (content, style)
3495+}
3496+
3497+/// The text of a block whose lines are content, not htmlang (`@head`,
3498+/// `@style`, `@markdown`): each line trimmed, verbatim text as is.
3499+fn block_text(block: &[Syntax]) -> String {
3500+ let mut text = String::new();
3501+ for_each_line(block, &mut |node| match node {
3502+ Syntax::Line { text: line, .. } => {
3503+ text.push_str(line.trim());
3504+ text.push('\n');
3505+ }
3506+ Syntax::Raw { text: raw, .. } => {
3507+ text.push_str(raw);
3508+ text.push('\n');
3509+ }
3510+ _ => {}
3511+ });
3512+ text
39723513}
39733514
39743515fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
crates/htmlang-core/src/syntax.rsnew file+517
@@ -0,0 +1,517 @@
1+//! Parsing: source text to a syntax tree, before anything is evaluated.
2+//!
3+//! This step knows the shape of the language: indentation, continuation
4+//! lines of attribute lists, verbatim blocks (`@raw """…"""`, the bodies of
5+//! `@markdown` and `@script`), multi-line strings, and the directives whose
6+//! structure spans several lines (`@if` / `@else` chains, `@each` with an
7+//! `@else`, function definitions). It reads no files and substitutes no
8+//! variables; `parser.rs` evaluates the tree.
9+
10+use std::collections::HashMap;
11+
12+#[derive(Clone, Debug)]
13+pub(crate) enum LineContent {
14+ Normal(String),
15+ Raw(String),
16+}
17+
18+/// One logical source line (continuations joined), without its indented
19+/// children.
20+#[derive(Clone, Debug)]
21+pub(crate) struct Line {
22+ pub indent: usize,
23+ pub content: LineContent,
24+ pub line_num: usize,
25+}
26+
27+#[derive(Clone, Debug)]
28+pub(crate) enum Syntax {
29+ /// Verbatim text.
30+ Raw { line: usize, text: String },
31+ /// An element, text, a function call or a single-line directive, with
32+ /// the lines indented under it.
33+ Line {
34+ line: usize,
35+ indent: usize,
36+ text: String,
37+ children: Vec<Syntax>,
38+ },
39+ /// `@let NAME $param $param=default` with an indented body.
40+ Function {
41+ line: usize,
42+ name: String,
43+ params: Vec<String>,
44+ defaults: HashMap<String, String>,
45+ body: Vec<Syntax>,
46+ },
47+ /// `@if` with its `@else if` and `@else` branches.
48+ If { branches: Vec<Branch> },
49+ /// `@each $var in LIST` with its body and the `@else` for an empty list.
50+ Each {
51+ line: usize,
52+ header: String,
53+ body: Vec<Syntax>,
54+ empty: Vec<Syntax>,
55+ },
56+}
57+
58+#[derive(Clone, Debug)]
59+pub(crate) struct Branch {
60+ pub line: usize,
61+ /// `None` for `@else`.
62+ pub condition: Option<String>,
63+ pub body: Vec<Syntax>,
64+}
65+
66+impl Syntax {
67+ pub fn line(&self) -> usize {
68+ match self {
69+ Syntax::Raw { line, .. }
70+ | Syntax::Line { line, .. }
71+ | Syntax::Function { line, .. }
72+ | Syntax::Each { line, .. } => *line,
73+ Syntax::If { branches } => branches.first().map_or(0, |b| b.line),
74+ }
75+ }
76+
77+ /// The node's first source line, for diagnostics.
78+ pub fn source(&self) -> String {
79+ match self {
80+ Syntax::Raw { text, .. } | Syntax::Line { text, .. } => text.clone(),
81+ Syntax::Function { name, params, defaults, .. } => {
82+ let mut out = format!("@let {}", name);
83+ for param in params {
84+ match defaults.get(param) {
85+ Some(default) => out.push_str(&format!(" ${}={}", param, default)),
86+ None => out.push_str(&format!(" ${}", param)),
87+ }
88+ }
89+ out
90+ }
91+ Syntax::If { branches } => branches
92+ .first()
93+ .map(|b| format!("@if {}", b.condition.as_deref().unwrap_or("")))
94+ .unwrap_or_default(),
95+ Syntax::Each { header, .. } => format!("@each {}", header),
96+ }
97+ }
98+}
99+
100+/// Parse source text into a syntax tree.
101+pub(crate) fn parse(input: &str) -> Vec<Syntax> {
102+ let lines = preprocess(input);
103+ let mut pos = 0;
104+ build(&lines, &mut pos, None, false)
105+}
106+
107+/// Build the nodes for the lines from `pos` indented deeper than `parent`.
108+/// A `plain` block is text (the body of `@style`, `@head` or `@keyframes`),
109+/// so no directive in it is given structure.
110+fn build(lines: &[Line], pos: &mut usize, parent: Option<usize>, plain: bool) -> Vec<Syntax> {
111+ let mut nodes = Vec::new();
112+ let deeper = |line: &Line| parent.is_none_or(|p| line.indent > p);
113+ while *pos < lines.len() && deeper(&lines[*pos]) {
114+ let line = &lines[*pos];
115+ *pos += 1;
116+ let text = match &line.content {
117+ LineContent::Raw(text) => {
118+ nodes.push(Syntax::Raw {
119+ line: line.line_num,
120+ text: text.clone(),
121+ });
122+ continue;
123+ }
124+ LineContent::Normal(text) => text.clone(),
125+ };
126+ // Text and one-line directives take no body: lines indented under
127+ // them are their siblings.
128+ if !plain && !takes_body(&text) {
129+ nodes.push(Syntax::Line {
130+ line: line.line_num,
131+ indent: line.indent,
132+ text,
133+ children: Vec::new(),
134+ });
135+ continue;
136+ }
137+ let text_body = plain
138+ || text == "@style"
139+ || text == "@head"
140+ || text.starts_with("@head ")
141+ || text.starts_with("@keyframes ");
142+ let children = build(lines, pos, Some(line.indent), text_body);
143+ if plain {
144+ nodes.push(Syntax::Line {
145+ line: line.line_num,
146+ indent: line.indent,
147+ text,
148+ children,
149+ });
150+ continue;
151+ }
152+
153+ if let Some(condition) = text.strip_prefix("@if ") {
154+ let mut branches = vec![Branch {
155+ line: line.line_num,
156+ condition: Some(condition.trim().to_string()),
157+ body: children,
158+ }];
159+ while let Some((next_line, next)) = sibling(lines, *pos, line.indent) {
160+ let condition = if let Some(condition) = next.strip_prefix("@else if ") {
161+ Some(condition.trim().to_string())
162+ } else if next == "@else" {
163+ None
164+ } else {
165+ break;
166+ };
167+ *pos += 1;
168+ let last = condition.is_none();
169+ branches.push(Branch {
170+ line: next_line,
171+ condition,
172+ body: build(lines, pos, Some(line.indent), false),
173+ });
174+ if last {
175+ break;
176+ }
177+ }
178+ nodes.push(Syntax::If { branches });
179+ continue;
180+ }
181+
182+ if let Some(header) = text.strip_prefix("@each ") {
183+ let mut empty = Vec::new();
184+ if let Some((_, "@else")) = sibling(lines, *pos, line.indent) {
185+ *pos += 1;
186+ empty = build(lines, pos, Some(line.indent), false);
187+ }
188+ nodes.push(Syntax::Each {
189+ line: line.line_num,
190+ header: header.trim().to_string(),
191+ body: children,
192+ empty,
193+ });
194+ continue;
195+ }
196+
197+ if let Some(rest) = text.strip_prefix("@let ")
198+ && !children.is_empty()
199+ && let Some((name, params)) = rest.split_whitespace().collect::<Vec<_>>().split_first()
200+ {
201+ let mut names = Vec::new();
202+ let mut defaults = HashMap::new();
203+ for param in params {
204+ let param = param.strip_prefix('$').unwrap_or(param);
205+ match param.split_once('=') {
206+ Some((param, default)) => {
207+ names.push(param.to_string());
208+ defaults.insert(param.to_string(), default.to_string());
209+ }
210+ None => names.push(param.to_string()),
211+ }
212+ }
213+ nodes.push(Syntax::Function {
214+ line: line.line_num,
215+ name: name.to_string(),
216+ params: names,
217+ defaults,
218+ body: children,
219+ });
220+ continue;
221+ }
222+
223+ nodes.push(Syntax::Line {
224+ line: line.line_num,
225+ indent: line.indent,
226+ text,
227+ children,
228+ });
229+ }
230+ nodes
231+}
232+
233+/// Whether the lines indented under `text` belong to it.
234+fn takes_body(text: &str) -> bool {
235+ const ONE_LINE: &[&str] = &["@page", "@meta", "@warn", "@assert", "@data", "@include"];
236+ let name = text.split([' ', '[']).next().unwrap_or("");
237+ (text.starts_with('@') || text.starts_with('[')) && !ONE_LINE.contains(&name)
238+}
239+
240+/// The line at `pos` if it is a sibling at `indent`, with its text.
241+fn sibling(lines: &[Line], pos: usize, indent: usize) -> Option<(usize, &str)> {
242+ let line = lines.get(pos).filter(|l| l.indent == indent)?;
243+ match &line.content {
244+ LineContent::Normal(text) => Some((line.line_num, text.trim())),
245+ LineContent::Raw(_) => None,
246+ }
247+}
248+
249+/// For `@let name """…` (or `@let name = """…`) without the closing `"""`
250+/// on the same line, the text after the opening quotes.
251+fn opens_multiline_string(line: &str) -> Option<&str> {
252+ let (_, value) = line.strip_prefix("@let ")?.trim_start().split_once(' ')?;
253+ let value = value.trim_start();
254+ let value = value.strip_prefix('=').map_or(value, str::trim_start);
255+ let open = value.strip_prefix("\"\"\"")?;
256+ if open.len() >= 3 && open.ends_with("\"\"\"") {
257+ return None;
258+ }
259+ Some(open)
260+}
261+
262+pub(crate) fn preprocess(input: &str) -> Vec<Line> {
263+ let raw_lines: Vec<&str> = input.lines().collect();
264+ let mut lines = Vec::new();
265+ let mut i = 0;
266+
267+ while i < raw_lines.len() {
268+ let line = raw_lines[i];
269+ let trimmed = line.trim();
270+
271+ if trimmed.is_empty() || trimmed.starts_with("--") {
272+ i += 1;
273+ continue;
274+ }
275+
276+ let indent = line.len() - line.trim_start().len();
277+
278+ // Inline `@markdown` and `@script` blocks keep their bodies verbatim:
279+ // in Markdown, blank lines separate paragraphs, `---` is a rule (not
280+ // a comment) and code indentation matters; in JavaScript, newlines,
281+ // `{...}` and `$` must reach the output untouched.
282+ let verbatim_body = trimmed == "@markdown"
283+ || trimmed == "@script"
284+ || trimmed.starts_with("@script ")
285+ || trimmed.starts_with("@script[");
286+ if verbatim_body {
287+ lines.push(Line {
288+ indent,
289+ content: LineContent::Normal(trimmed.to_string()),
290+ line_num: i + 1,
291+ });
292+ let body_start = i + 1;
293+ let mut body_end = body_start;
294+ let mut j = body_start;
295+ while j < raw_lines.len() {
296+ let l = raw_lines[j];
297+ if l.trim().is_empty() {
298+ j += 1;
299+ continue;
300+ }
301+ if l.len() - l.trim_start().len() <= indent {
302+ break;
303+ }
304+ j += 1;
305+ body_end = j;
306+ }
307+ let body = &raw_lines[body_start..body_end];
308+ let body_indent = body
309+ .iter()
310+ .filter(|l| !l.trim().is_empty())
311+ .map(|l| l.len() - l.trim_start().len())
312+ .min()
313+ .unwrap_or(0);
314+ if !body.is_empty() {
315+ let text: Vec<&str> = body
316+ .iter()
317+ .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
318+ .collect();
319+ lines.push(Line {
320+ indent: indent + 1,
321+ content: LineContent::Raw(text.join("\n")),
322+ line_num: body_start + 1,
323+ });
324+ }
325+ i = body_end.max(body_start);
326+ continue;
327+ }
328+
329+ // Handle @raw """..."""
330+ if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
331+ let after_raw = raw_rest.trim_start();
332+ if let Some(after_open) = after_raw.strip_prefix("\"\"\"") {
333+ // Single-line: @raw """content"""
334+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
335+ let content = &after_open[..after_open.len() - 3];
336+ lines.push(Line {
337+ indent,
338+ content: LineContent::Raw(content.to_string()),
339+ line_num: i + 1,
340+ });
341+ i += 1;
342+ continue;
343+ }
344+
345+ // Multiline: collect until closing """
346+ let mut raw_content = String::new();
347+ if !after_open.is_empty() {
348+ raw_content.push_str(after_open);
349+ raw_content.push('\n');
350+ }
351+ i += 1;
352+ while i < raw_lines.len() {
353+ if raw_lines[i].trim() == "\"\"\"" {
354+ i += 1;
355+ break;
356+ }
357+ raw_content.push_str(raw_lines[i]);
358+ raw_content.push('\n');
359+ i += 1;
360+ }
361+
362+ lines.push(Line {
363+ indent,
364+ content: LineContent::Raw(raw_content.trim_end_matches('\n').to_string()),
365+ line_num: i,
366+ });
367+ continue;
368+ }
369+ }
370+
371+ // `@let name """` opens a multi-line string running to a `"""` line;
372+ // it becomes one line holding the whole value.
373+ if let Some(open) = opens_multiline_string(trimmed) {
374+ let mut value: Vec<&str> = Vec::new();
375+ if !open.is_empty() {
376+ value.push(open);
377+ }
378+ let first_line_num = i + 1;
379+ i += 1;
380+ while i < raw_lines.len() {
381+ let next = raw_lines[i].trim();
382+ i += 1;
383+ if next == "\"\"\"" {
384+ break;
385+ }
386+ if !next.is_empty() {
387+ value.push(next);
388+ }
389+ }
390+ let head = &trimmed[..trimmed.len() - open.len()];
391+ lines.push(Line {
392+ indent,
393+ content: LineContent::Normal(format!("{}{}\"\"\"", head, value.join("\n"))),
394+ line_num: first_line_num,
395+ });
396+ continue;
397+ }
398+
399+ // Join continuation lines for multi-line attribute brackets
400+ let first_line_num = i + 1;
401+ let mut full = trimmed.to_string();
402+ while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() {
403+ i += 1;
404+ let next = raw_lines[i].trim();
405+ if next.is_empty() || next.starts_with("--") {
406+ continue;
407+ }
408+ full.push(' ');
409+ full.push_str(next);
410+ }
411+
412+ lines.push(Line {
413+ indent,
414+ content: LineContent::Normal(full),
415+ line_num: first_line_num,
416+ });
417+ i += 1;
418+ }
419+
420+ lines
421+}
422+
423+/// Bracket depth left open at the end of `line`, counting only attribute
424+/// lists: a `[` that starts the line or follows an `@name` token (optionally
425+/// with one more word, as in `@let name [`). Brackets in text content, such
426+/// as `@text [bold] Use [ to open`, are ignored.
427+pub(crate) fn open_attr_depth(line: &str) -> i32 {
428+ if !line.starts_with('@') && !line.starts_with('[') {
429+ // Text: an inline `{@name [` whose list runs onto the next line
430+ let Some(start) = line.rfind("{@") else {
431+ return 0;
432+ };
433+ let inline = &line[start + 1..];
434+ return if inline.contains('}') { 0 } else { open_attr_depth(inline) };
435+ }
436+ let bytes = line.as_bytes();
437+ let mut depth: i32 = 0;
438+ for (i, &b) in bytes.iter().enumerate() {
439+ match b {
440+ b'[' if depth > 0 => depth += 1,
441+ b'[' => {
442+ // Is this the start of an attribute list?
443+ let before = line[..i].trim_end();
444+ let last_directive = before
445+ .rsplit([']', '>'])
446+ .next()
447+ .unwrap_or("")
448+ .trim();
449+ let tokens: Vec<&str> = last_directive.split_whitespace().collect();
450+ let starts_list = match tokens.as_slice() {
451+ [] => before.is_empty() || before.ends_with('>'),
452+ [name] => name.starts_with('@'),
453+ [name, _] => name.starts_with('@'),
454+ _ => false,
455+ };
456+ if starts_list {
457+ depth = 1;
458+ } else if before.ends_with(']') {
459+ // Text after a closed attribute list: stop scanning.
460+ return 0;
461+ }
462+ }
463+ b']' if depth > 0 => depth -= 1,
464+ _ => {}
465+ }
466+ }
467+ depth
468+}
469+
470+
471+#[cfg(test)]
472+mod tests {
473+ use super::*;
474+
475+ fn kinds(nodes: &[Syntax]) -> Vec<&'static str> {
476+ nodes
477+ .iter()
478+ .map(|n| match n {
479+ Syntax::Raw { .. } => "raw",
480+ Syntax::Line { .. } => "line",
481+ Syntax::Function { .. } => "function",
482+ Syntax::If { .. } => "if",
483+ Syntax::Each { .. } => "each",
484+ })
485+ .collect()
486+ }
487+
488+ #[test]
489+ fn groups_control_flow_and_definitions() {
490+ let tree = parse(
491+ "@let card $title $icon=x\n @el\n @children\n@if $a\n A\n@else if $b\n B\n@else\n C\n@each $x in 1..3\n $x\n@else\n none\n@text done\n",
492+ );
493+ assert_eq!(kinds(&tree), ["function", "if", "each", "line"]);
494+ let Syntax::Function { name, params, defaults, body, .. } = &tree[0] else { unreachable!() };
495+ assert_eq!((name.as_str(), params.len(), defaults["icon"].as_str()), ("card", 2, "x"));
496+ assert_eq!(kinds(body), ["line"]);
497+ let Syntax::If { branches } = &tree[1] else { unreachable!() };
498+ let conditions: Vec<_> = branches.iter().map(|b| b.condition.as_deref()).collect();
499+ assert_eq!(conditions, [Some("$a"), Some("$b"), None]);
500+ let Syntax::Each { empty, .. } = &tree[2] else { unreachable!() };
501+ assert_eq!(kinds(empty), ["line"]);
502+ }
503+
504+ #[test]
505+ fn text_bodies_stay_plain() {
506+ let tree = parse("@style\n @if x {\n a\n }\n");
507+ let Syntax::Line { children, .. } = &tree[0] else { unreachable!() };
508+ assert_eq!(kinds(children), ["line", "line"]);
509+ }
510+
511+ #[test]
512+ fn multiline_strings_become_one_line() {
513+ let tree = parse("@let msg \"\"\"\n Hello\n there\n\"\"\"\n@text $msg\n");
514+ assert_eq!(kinds(&tree), ["line", "line"]);
515+ assert_eq!(tree[0].source(), "@let msg \"\"\"Hello\nthere\"\"\"");
516+ }
517+}
examples/demo.html+2−2
@@ -1,3 +1,3 @@
1−<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>htmlang Demo</title><meta name="theme-color" content="#3b82f6"><link rel="preload" href="https://picsum.photos/72" as="image"><link rel="preload" href="https://picsum.photos/640/200" as="image"><link rel="dns-prefetch" href="//github.com"><link rel="dns-prefetch" href="//picsum.photos"><meta name="description" content="A one-page tour of htmlang."><meta property="og:title" content="htmlang"><meta property="og:type" content="website"><style>*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}:root{--primary:#3b82f6;--ink:#0f172a;--muted:#64748b;--subtle:#f8fafc;--h0:1px solid #e5e7eb;}@layer htmlang{.a{display:flex;flex-direction:column;max-width:960px;margin-left:auto;margin-right:auto;padding:32px;gap:24px;background:white;}.b{display:flex;flex-direction:column;gap:8px;}.c{display:flex;flex-direction:column;gap:6px;color:var(--muted);font-size:13px;}.d{display:flex;flex-direction:row;gap:6px;align-items:center;}.e{color:var(--muted);}.f{color:var(--ink);}.g{display:flex;flex-direction:row;gap:16px;align-items:center;}.h{display:flex;flex-direction:column;flex:1;min-width:0;gap:6px;}.i{font-weight:bold;font-size:40px;color:var(--ink);}.j{margin:0;line-height:1.6;color:var(--muted);}.k{font-weight:bold;color:var(--primary);}.l{color:var(--primary);text-decoration:underline;}.m{width:72px;height:72px;border-radius:36px;}.n{border-top:var(--h0);}.o{display:flex;flex-direction:column;gap:12px;padding-block:8px;}.p{font-weight:bold;font-size:22px;color:var(--ink);}.q{display:flex;flex-direction:row;flex-wrap:wrap;gap:8px;}.r{display:flex;flex-direction:column;padding:16px;background:white;border-radius:8px;border:var(--h0);box-shadow:0 1px 3px rgba(0,0,0,0.05);cursor:pointer;transition:all 0.2s ease;animation:fade-up 0.3s ease;}.s{display:flex;flex-direction:row;gap:10px;align-items:center;}.t{font-size:20px;color:var(--primary);}.u{font-weight:bold;font-size:16px;color:var(--ink);}.v{margin:0;line-height:1.7;}.w{font-weight:bold;}.x{font-style:italic;}.y{padding:2px 6px;background:#f1f5f9;border-radius:4px;font-family:monospace;font-size:13px;}.z{background:#fef08a;}.aa{display:flex;flex-direction:column;margin:0;padding-inline-start:16px;border-left:3px solid var(--primary);color:var(--muted);font-style:italic;}.ab{margin:0;white-space:pre;background:var(--ink);color:#e2e8f0;padding:16px;border-radius:8px;font-family:monospace;font-size:13px;overflow-x:auto;}.ac{display:flex;flex-direction:column;gap:6px;}.ad{display:flex;flex-direction:row;gap:10px;align-items:baseline;}.ae{font-weight:bold;min-width:80px;}.af{color:var(--muted);font-style:italic;}.ag{display:flex;flex-direction:row;gap:6px;}.ah{display:flex;flex-direction:column;width:18px;height:18px;border-radius:9px;background:var(--primary);opacity:0.4;}.ai{display:flex;flex-direction:row;gap:4px;}.aj{display:flex;flex-direction:column;width:24px;height:24px;border-radius:4px;background:var(--primary);color:white;text-align:center;font-size:12px;font-weight:bold;line-height:24px;}.ak{font-weight:bold;min-width:64px;}.al{color:var(--primary);font-weight:bold;}.am{color:#10b981;font-weight:bold;}.an{display:flex;flex-direction:column;padding:12px;border-radius:6px;background:var(--primary);color:white;}.ao{font-family:ui-monospace,monospace;}.ap{display:flex;flex-direction:column;gap:10px;max-width:420px;}.aq{font-weight:bold;font-size:13px;}.ar{padding:10px;border:var(--h0);border-radius:6px;transition:border 0.15s ease;}.as{padding:10px;border:var(--h0);border-radius:6px;}.at{display:flex;flex-direction:row;gap:8px;}.au{padding:10px 16px;background:var(--primary);color:white;font-weight:bold;border-radius:6px;border:0 solid currentColor;transition:background 0.15s ease;cursor:pointer;}.av{padding:10px 16px;background:white;border:var(--h0);border-radius:6px;cursor:pointer;}.aw{display:flex;flex-direction:row;gap:16px;flex-wrap:wrap;}.ax{display:flex;flex-direction:column;flex:1;gap:8px;min-width:240px;}.ay{margin:0;padding-left:0;gap:4px;list-style:disc;padding-inline-start:20px;}.az{flex-direction:column;display:list-item;}.ba{margin:0;padding-left:0;list-style:decimal;padding-inline-start:20px;}.bb{text-align:left;padding:8px;}.bc{text-align:right;padding:8px;}.bd{padding:8px;border-top:var(--h0);}.be{padding:8px;border-top:var(--h0);text-align:right;}.bf{display:flex;flex-direction:column;margin:0;gap:6px;}.bg{border-radius:8px;}.bh{color:var(--muted);font-size:13px;text-align:center;}.bi{display:flex;flex-direction:column;padding:12px;border:var(--h0);border-radius:6px;background:var(--subtle);}.bj{font-weight:bold;cursor:pointer;}.bk{margin:0;color:var(--muted);line-height:1.6;padding-block-start:6px;}.bl{flex-direction:column;display:grid;grid-template-columns:1fr 2fr;grid-template-areas:"h h" "s m" "f f";gap:8px;padding:8px;background:var(--subtle);border-radius:8px;}.bm{display:flex;flex-direction:column;grid-area:h;padding:10px;background:var(--primary);color:white;border-radius:6px;text-align:center;}.bn{display:flex;flex-direction:column;grid-area:s;padding:10px;background:white;border-radius:6px;border:var(--h0);}.bo{display:flex;flex-direction:column;grid-area:m;padding:10px;background:white;border-radius:6px;border:var(--h0);}.bp{display:flex;flex-direction:column;grid-area:f;padding:10px;background:var(--ink);color:white;border-radius:6px;text-align:center;}.bq{gap:4px;font-style:normal;}.br,.bx{color:var(--primary);}.bs{font-size:13px;color:var(--muted);}.bt{font-size:13px;}.bu{display:flex;flex-direction:column;padding-block-start:24px;margin-block-start:24px;border-top:var(--h0);gap:8px;}.bv{display:flex;flex-direction:row;gap:8px;align-items:center;}.bw{display:flex;flex-direction:column;padding:12px 20px;background:var(--primary);color:white;font-weight:bold;border-radius:8px;transition:all 0.15s ease;cursor:pointer;}.by{display:flex;flex-direction:column;flex:1;min-width:0;}.bz{color:var(--muted);font-size:13px;}.r:hover{box-shadow:0 4px 12px rgba(0,0,0,0.08);}.au:hover,.bw:hover{background:#3575dd;}.bx:hover{text-decoration:underline;}.an::before{content:"→ ";font-weight:bold;}.ar:focus{border:1px solid var(--primary);}.ar:valid{border:1px solid #10b981;}.ar:invalid{border:1px solid #ef4444;}.au:active,.bw:active{background:#2f68c5;}.bx:visited{color:#8b5cf6;}@media(prefers-color-scheme:dark){.a{background:#0b1220;color:#e2e8f0;}.i{color:white;}}@media print{.bu{display:none;}}}@keyframes fade-up{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}</style></head><body><noscript><style>.needs-js{display:none}</style></noscript><div class="a"><header class="b"><nav class="c"><div class="d"><a href="/" class="e">Home</a><span>/</span><a href="/demos" class="e">Demos</a><span>/</span><span class="f">htmlang</span></div></nav><div class="g"><div class="h"><span class="i">Welcome to htmlang</span><p class="j">A minimalist layout language that compiles to <span class="k">static HTML</span> — no JS, no build step. Visit the <a href="https://github.com" rel="noopener noreferrer" target="_blank" class="l">repo</a> for more.</p></div><img src="https://picsum.photos/72" class="m" alt="Avatar" fetchpriority="high"></div></header><hr class="n"><section class="o"><span class="p">Features</span><div class="q"><article class="r"><div class="s"><span class="t">✦</span><span class="u">Simple</span></div><span>Write layouts without CSS classes.</span></article><article class="r"><div class="s"><span class="t">⚡</span><span class="u">Fast</span></div><span>Compiles to a single self-contained HTML file.</span></article><article class="r"><div class="s"><span class="t">○</span><span class="u">Minimal</span></div><span>Zero runtime JavaScript by default.</span></article><article class="r"><div class="s"><span class="t">◆</span><span class="u">Typed</span></div><span>LSP with diagnostics and go-to-definition.</span></article></div></section><section class="o"><span class="p">Typography</span><p class="v">A quick brown fox jumps over <span class="w">the lazy</span> <span class="x">dog</span>. Press <kbd class="y">Ctrl+K</kbd> to search. <mark class="z">Highlighted</mark> uses mark. <abbr title="HyperText Markup Language + language">htmlang</abbr> is an abbreviation.</p><blockquote class="aa"><span>Layout is code. Style is code. Everything should compose.</span><cite>— Anonymous</cite></blockquote><pre class="ab">@fn button $label
1+<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>htmlang Demo</title><link rel="preload" href="https://picsum.photos/72" as="image"><link rel="preload" href="https://picsum.photos/640/200" as="image"><meta name="description" content="A one-page tour of htmlang."><meta name="theme-color" content="#3b82f6"><meta property="og:title" content="htmlang"><meta property="og:type" content="website"><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}}:root{--primary:#3b82f6;--ink:#0f172a;--muted:#64748b;--subtle:#f8fafc;}@layer htmlang{.a{display:flex;flex-direction:column;max-width:960px;margin-left:auto;margin-right:auto;padding:32px;gap:24px;background:white;}.b{display:flex;flex-direction:column;gap:8px;}.c{display:flex;flex-direction:column;gap:6px;color:var(--muted);font-size:13px;}.d{display:flex;flex-direction:row;gap:6px;align-items:center;}.e{color:var(--muted);}.f{color:var(--ink);}.g{display:flex;flex-direction:row;gap:16px;align-items:center;}.h{display:flex;flex-direction:column;flex:1;min-width:0;gap:6px;}.i{font-weight:bold;font-size:40px;color:var(--ink);}.j{margin:0;line-height:1.6;color:var(--muted);}.k{font-weight:bold;color:var(--primary);}.l{color:var(--primary);text-decoration:underline;}.m{width:72px;height:72px;border-radius:36px;}.n{border-top:1px solid #e5e7eb;}.o{display:flex;flex-direction:column;gap:12px;padding-block:8px;}.p{font-weight:bold;font-size:22px;color:var(--ink);}.q{display:flex;flex-direction:row;flex-wrap:wrap;gap:8px;}.r{display:flex;flex-direction:column;padding:16px;background:white;border-radius:8px;border:1px solid #e5e7eb;box-shadow:0 1px 3px rgba(0,0,0,0.05);cursor:pointer;transition:all 0.2s ease;animation:fade-up 0.3s ease;}.s{display:flex;flex-direction:row;gap:10px;align-items:center;}.t{font-size:20px;color:var(--primary);}.u{font-weight:bold;font-size:16px;color:var(--ink);}.v{margin:0;line-height:1.7;}.w{font-weight:bold;}.x{font-style:italic;}.y{padding:2px 6px;background:#f1f5f9;border-radius:4px;font-family:monospace;font-size:13px;}.z{background:#fef08a;}.aa{display:flex;flex-direction:column;margin:0;padding-inline-start:16px;border-left:3px solid var(--primary);color:var(--muted);font-style:italic;}.ab{margin:0;white-space:pre;background:var(--ink);color:#e2e8f0;padding:16px;border-radius:8px;font-family:monospace;font-size:13px;overflow-x:auto;}.ac{display:flex;flex-direction:column;gap:6px;}.ad{display:flex;flex-direction:row;gap:10px;align-items:baseline;}.ae{font-weight:bold;min-width:80px;}.af{color:var(--muted);font-style:italic;}.ag{display:flex;flex-direction:row;gap:6px;}.ah{display:flex;flex-direction:column;width:18px;height:18px;border-radius:9px;background:var(--primary);opacity:0.4;}.ai{display:flex;flex-direction:row;gap:4px;}.aj{display:flex;flex-direction:column;width:24px;height:24px;border-radius:4px;background:var(--primary);color:white;text-align:center;font-size:12px;font-weight:bold;line-height:24px;}.ak{font-weight:bold;min-width:64px;}.al{color:var(--primary);font-weight:bold;}.am{color:#10b981;font-weight:bold;}.an{display:flex;flex-direction:column;padding:12px;border-radius:6px;background:var(--primary);color:white;}.ao{font-family:ui-monospace,monospace;}.ap{display:flex;flex-direction:column;gap:10px;max-width:420px;}.aq{font-weight:bold;font-size:13px;}.ar{padding:10px;border:1px solid #e5e7eb;border-radius:6px;transition:border 0.15s ease;}.as{padding:10px;border:1px solid #e5e7eb;border-radius:6px;}.at{display:flex;flex-direction:row;gap:8px;}.au{padding:10px 16px;background:var(--primary);color:white;font-weight:bold;border-radius:6px;border:0 solid currentColor;transition:background 0.15s ease;cursor:pointer;}.av{padding:10px 16px;background:white;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;}.aw{display:flex;flex-direction:row;gap:16px;flex-wrap:wrap;}.ax{display:flex;flex-direction:column;flex:1;gap:8px;min-width:240px;}.ay{margin:0;padding-left:0;gap:4px;list-style:disc;padding-inline-start:20px;}.az{flex-direction:column;display:list-item;}.a0{margin:0;padding-left:0;list-style:decimal;padding-inline-start:20px;}.a1{text-align:left;padding:8px;}.a2{text-align:right;padding:8px;}.a3{padding:8px;border-top:1px solid #e5e7eb;}.a4{padding:8px;border-top:1px solid #e5e7eb;text-align:right;}.a5{display:flex;flex-direction:column;margin:0;gap:6px;}.a6{border-radius:8px;}.a7{color:var(--muted);font-size:13px;text-align:center;}.a8{display:flex;flex-direction:column;padding:12px;border:1px solid #e5e7eb;border-radius:6px;background:var(--subtle);}.a9{font-weight:bold;cursor:pointer;}.ba{margin:0;color:var(--muted);line-height:1.6;padding-block-start:6px;}.bb{flex-direction:column;display:grid;grid-template-columns:1fr 2fr;grid-template-areas:"h h" "s m" "f f";gap:8px;padding:8px;background:var(--subtle);border-radius:8px;}.bc{display:flex;flex-direction:column;grid-area:h;padding:10px;background:var(--primary);color:white;border-radius:6px;text-align:center;}.bd{display:flex;flex-direction:column;grid-area:s;padding:10px;background:white;border-radius:6px;border:1px solid #e5e7eb;}.be{display:flex;flex-direction:column;grid-area:m;padding:10px;background:white;border-radius:6px;border:1px solid #e5e7eb;}.bf{display:flex;flex-direction:column;grid-area:f;padding:10px;background:var(--ink);color:white;border-radius:6px;text-align:center;}.bg{gap:4px;font-style:normal;}.bh,.bn{color:var(--primary);}.bi{font-size:13px;color:var(--muted);}.bj{font-size:13px;}.bk{display:flex;flex-direction:column;padding-block-start:24px;margin-block-start:24px;border-top:1px solid #e5e7eb;gap:8px;}.bl{display:flex;flex-direction:row;gap:8px;align-items:center;}.bm{display:flex;flex-direction:column;padding:12px 20px;background:var(--primary);color:white;font-weight:bold;border-radius:8px;transition:all 0.15s ease;cursor:pointer;}.bo{display:flex;flex-direction:column;flex:1;min-width:0;}.bp{color:var(--muted);font-size:13px;}.r:hover{box-shadow:0 4px 12px rgba(0,0,0,0.08);}.au:hover,.bm:hover{background:#3575dd;}.bn:hover{text-decoration:underline;}.an::before{content:"→ ";font-weight:bold;}.ar:focus{border:1px solid var(--primary);}.ar:valid{border:1px solid #10b981;}.ar:invalid{border:1px solid #ef4444;}.au:active,.bm:active{background:#2f68c5;}.bn:visited{color:#8b5cf6;}@media(prefers-color-scheme:dark){.a{background:#0b1220;color:#e2e8f0;}.i{color:white;}}@media print{.bk{display:none;}}}@keyframes fade-up{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}</style></head><body><noscript><style>.needs-js{display:none}</style></noscript><div class="a"><header class="b"><nav class="c"><div class="d"><a href="/" class="e">Home</a><span>/</span><a href="/demos" class="e">Demos</a><span>/</span><span class="f">htmlang</span></div></nav><div class="g"><div class="h"><span class="i">Welcome to htmlang</span><p class="j">A minimalist layout language that compiles to <span class="k">static HTML</span> — no JS, no build step. Visit the <a href="https://github.com" rel="noopener noreferrer" target="_blank" class="l">repo</a> for more.</p></div><img src="https://picsum.photos/72" class="m" alt="Avatar" fetchpriority="high"></div></header><hr class="n"><section class="o"><span class="p">Features</span><div class="q"><article class="r"><div class="s"><span class="t">✦</span><span class="u">Simple</span></div><span>Write layouts without CSS classes.</span></article><article class="r"><div class="s"><span class="t">⚡</span><span class="u">Fast</span></div><span>Compiles to a single self-contained HTML file.</span></article><article class="r"><div class="s"><span class="t">○</span><span class="u">Minimal</span></div><span>Zero runtime JavaScript by default.</span></article><article class="r"><div class="s"><span class="t">◆</span><span class="u">Typed</span></div><span>LSP with diagnostics and go-to-definition.</span></article></div></section><section class="o"><span class="p">Typography</span><p class="v">A quick brown fox jumps over <span class="w">the lazy</span> <span class="x">dog</span>. Press <kbd class="y">Ctrl+K</kbd> to search. <mark class="z">Highlighted</mark> uses mark. <abbr title="HyperText Markup Language + language">htmlang</abbr> is an abbreviation.</p><blockquote class="aa"><span>Layout is code. Style is code. Everything should compose.</span><cite>— Anonymous</cite></blockquote><pre class="ab">@let button $label
22 @el [padding 12, background blue]
3− @text $label</pre></section><section class="o"><span class="p">Control flow</span><div class="ac"><div class="ad"><span class="ae">Alice</span><span class="e">Engineer</span></div><div class="ad"><span class="ae">Bob</span><span class="e">Designer</span></div><div class="ad"><span class="ae">Carol</span><span class="e">PM</span></div></div><span class="af" data-state="">(Nothing — @each ... @else fallback)</span><div class="ag"><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div></div><div class="ai"><div class="aj"><span>1</span></div><div class="aj"><span>2</span></div><div class="aj"><span>3</span></div><div class="aj"><span>4</span></div><div class="aj"><span>5</span></div><div class="aj"><span>6</span></div></div><div class="d"><span class="ak">Count:</span><span class="al">3 items</span></div><span class="am">✓ Ready</span><div class="an"><span>Conditional styling via <code class="ao">if(cond, a, b)</code>.</span></div></section><section class="o"><span class="p">Forms</span><form action="/subscribe" class="ap"><label class="aq" for="email">Email</label><input class="ar" id="email" type="email" name="email" placeholder="you@example.com" required><label class="aq" for="role">Role</label><select class="as" id="role" name="role"><option value="dev">Developer</option><option value="designer">Designer</option><option value="other">Other</option></select><label class="aq" for="msg">Message</label><textarea class="as" id="msg" name="msg" rows="3"></textarea><div class="at"><button class="au" type="submit">Subscribe</button><button class="av" type="reset">Reset</button></div><progress value="60" max="100"></progress></form></section><section class="o"><span class="p">Data</span><div class="aw"><div class="ax"><span class="w">Unordered</span><ul class="ay"><li class="az">Composable with @each</li><li class="az">Indent to nest children</li><li class="az">Bold list items work too</li></ul><span class="w">Ordered</span><ol class="ba"><li class="az">First</li><li class="az">Second</li><li class="az">Third</li></ol></div><div class="ax"><span class="w">Table</span><table><thead><tr><th class="bb">Item</th><th class="bc">Qty</th></tr></thead><tbody><tr><td class="bd">Widgets</td><td class="be">12</td></tr><tr><td class="bd">Gadgets</td><td class="be">7</td></tr></tbody></table></div></div><figure class="bf"><img src="https://picsum.photos/640/200" class="bg" alt="A random placeholder" fetchpriority="high"><figcaption class="bh">A placeholder image.</figcaption></figure></section><section class="o"><span class="p">Disclosure + grid areas</span><details class="bi"><summary class="bj">What is htmlang?</summary><p class="bk">htmlang is a layout-first language that compiles to a single, self-contained HTML file with embedded CSS.</p></details><div class="bl"><div class="bm"><span>Header</span></div><div class="bn"><span>Sidebar</span></div><div class="bo"><span>Main</span></div><div class="bp"><span>Footer</span></div></div></section><section class="o"><span class="p">Misc</span><address class="bq"><span class="w">htmlang HQ</span><span>123 Flexbox Avenue</span><a href="mailto:hi@htmlang.dev" class="br">hi@htmlang.dev</a></address><div class="s"><span class="bs">Published</span><time class="bt" datetime="2026-04-16">April 16 2026</time></div><div class="s"><span class="bs">Confidence</span><meter value="8" min="0" max="10" low="3" high="7"></meter></div></section><footer class="bu"><div class="bv"><div class="bw"><a href="https://github.com" rel="noopener noreferrer" target="_blank">Get Started</a></div><a href="https://github.com" rel="noopener noreferrer" target="_blank" class="bx">Browse source</a><div class="by"></div><span class="bz">© 2026 htmlang</span></div></footer></div></body></html>
3+ @text $label</pre></section><section class="o"><span class="p">Control flow</span><div class="ac"><div class="ad"><span class="ae">Alice</span><span class="e">Engineer</span></div><div class="ad"><span class="ae">Bob</span><span class="e">Designer</span></div><div class="ad"><span class="ae">Carol</span><span class="e">PM</span></div></div><span class="af" data-state="">(Nothing — @each ... @else fallback)</span><div class="ag"><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div></div><div class="ai"><div class="aj"><span>1</span></div><div class="aj"><span>2</span></div><div class="aj"><span>3</span></div><div class="aj"><span>4</span></div><div class="aj"><span>5</span></div><div class="aj"><span>6</span></div></div><div class="d"><span class="ak">Count:</span><span class="al">3 items</span></div><span class="am">✓ Ready</span><div class="an"><span>Conditional styling via <code class="ao">if(cond, a, b)</code>.</span></div></section><section class="o"><span class="p">Forms</span><form action="/subscribe" class="ap"><label class="aq" for="email">Email</label><input class="ar" id="email" type="email" name="email" placeholder="you@example.com" required><label class="aq" for="role">Role</label><select class="as" id="role" name="role"><option value="dev">Developer</option><option value="designer">Designer</option><option value="other">Other</option></select><label class="aq" for="msg">Message</label><textarea class="as" id="msg" name="msg" rows="3"></textarea><div class="at"><button class="au" type="submit">Subscribe</button><button class="av" type="reset">Reset</button></div><progress value="60" max="100"></progress></form></section><section class="o"><span class="p">Data</span><div class="aw"><div class="ax"><span class="w">Unordered</span><ul class="ay"><li class="az">Composable with @each</li><li class="az">Indent to nest children</li><li class="az">Bold list items work too</li></ul><span class="w">Ordered</span><ol class="a0"><li class="az">First</li><li class="az">Second</li><li class="az">Third</li></ol></div><div class="ax"><span class="w">Table</span><table><thead><tr><th class="a1">Item</th><th class="a2">Qty</th></tr></thead><tbody><tr><td class="a3">Widgets</td><td class="a4">12</td></tr><tr><td class="a3">Gadgets</td><td class="a4">7</td></tr></tbody></table></div></div><figure class="a5"><img src="https://picsum.photos/640/200" class="a6" alt="A random placeholder" fetchpriority="high"><figcaption class="a7">A placeholder image.</figcaption></figure></section><section class="o"><span class="p">Disclosure + grid areas</span><details class="a8"><summary class="a9">What is htmlang?</summary><p class="ba">htmlang is a layout-first language that compiles to a single, self-contained HTML file with embedded CSS.</p></details><div class="bb"><div class="bc"><span>Header</span></div><div class="bd"><span>Sidebar</span></div><div class="be"><span>Main</span></div><div class="bf"><span>Footer</span></div></div></section><section class="o"><span class="p">Misc</span><address class="bg"><span class="w">htmlang HQ</span><span>123 Flexbox Avenue</span><a href="mailto:hi@htmlang.dev" class="bh">hi@htmlang.dev</a></address><div class="s"><span class="bi">Published</span><time class="bj" datetime="2026-04-16">April 16 2026</time></div><div class="s"><span class="bi">Confidence</span><meter value="8" min="0" max="10" low="3" high="7">8/10</meter></div></section><footer class="bk"><div class="bl"><div class="bm"><a href="https://github.com" rel="noopener noreferrer" target="_blank">Get Started</a></div><a href="https://github.com" rel="noopener noreferrer" target="_blank" class="bn">Browse source</a><div class="bo"></div><span class="bp">© 2026 htmlang</span></div></footer></div></body></html>
tests/regressions.rs+18
@@ -398,3 +398,21 @@ fn inline_svg_resolves_from_the_page_and_keeps_attributes() {
398398 let out = compile_in(&dir, "@image [inline, width 24, class=icon] icons/a.svg");
399399 assert!(out.contains(r#"<svg viewBox="0 0 24 24" width="24" class="icon">"#), "{}", out);
400400}
401+
402+#[test]
403+fn inline_element_attributes_can_continue_on_the_next_line() {
404+ let result = htmlang::parser::parse(
405+ "@paragraph\n Press {@kbd [\n padding 2 6, rounded 4\n ] Ctrl+K} to search.\n",
406+ );
407+ assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
408+ let html = htmlang::codegen::generate(&result.document);
409+ assert!(html.contains(">Ctrl+K</kbd> to search."), "{}", html);
410+}
411+
412+#[test]
413+fn lines_under_text_are_its_siblings() {
414+ let html = htmlang::codegen::generate(
415+ &htmlang::parser::parse("@el\n Some text\n more text\n").document,
416+ );
417+ assert!(html.contains("<span>Some text</span><span>more text</span>"), "{}", html);
418+}