1use std::collections::HashMap;
4
5use crate::ast::*;
6
7#[derive(Debug, Clone)]
8pub(crate) struct RawParseError {
9 pub message: String,
10 pub byte_offset: Option<usize>,
11}
12
13fn subslice_byte_offset(full: &str, tail: &str) -> usize {
14 let fp = full.as_ptr() as usize;
15 let tp = tail.as_ptr() as usize;
16 debug_assert!(tp >= fp && tp <= fp.saturating_add(full.len()));
17 tp.saturating_sub(fp)
18}
19
20#[derive(Debug, Clone, Default)]
24pub struct ComponentMeta {
25 pub description: Option<String>,
27 pub defaults: HashMap<String, String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct ComponentDef {
35 pub nodes: Vec<Node>,
36 pub meta: ComponentMeta,
37}
38
39pub struct ComponentFile {
77 pub components: HashMap<String, ComponentDef>,
78}
79
80#[tracing::instrument(skip(content), fields(len = content.len()))]
82pub fn parse_component_file(content: &str) -> Result<ComponentFile, String> {
83 parse_component_file_inner(content).map_err(|e| e.message)
84}
85
86pub(crate) fn parse_component_file_inner(content: &str) -> Result<ComponentFile, RawParseError> {
87 let (frontmatter_str, body, body_byte_in_file) = split_frontmatter_parts(content);
88
89 let mut meta_map: HashMap<String, ComponentMeta> = HashMap::new();
90 if let Some(toml_src) = frontmatter_str {
91 let toml_block_start = subslice_byte_offset(content, toml_src);
92 let value: toml::Value = toml_src
93 .parse()
94 .map_err(|e: toml::de::Error| RawParseError {
95 message: format!("TOML parse error in frontmatter: {e}"),
96 byte_offset: e.span().map(|r| toml_block_start + r.start),
97 })?;
98
99 if let toml::Value::Table(table) = value {
100 for (comp_name, comp_val) in &table {
101 if let toml::Value::Table(comp_table) = comp_val {
102 let mut meta = ComponentMeta::default();
103
104 if let Some(toml::Value::String(desc)) = comp_table.get("description") {
105 meta.description = Some(desc.clone());
106 }
107
108 if let Some(toml::Value::Table(defs)) = comp_table.get("defaults") {
109 for (k, v) in defs {
110 meta.defaults.insert(k.clone(), toml_value_to_expr(v));
111 }
112 }
113
114 meta_map.insert(comp_name.clone(), meta);
115 }
116 }
117 }
118 }
119
120 let sections = split_component_body_sections(body);
121 let mut components = HashMap::new();
122
123 for (name, section_content, sec_start_in_body) in sections {
124 let nodes = parse_template_raw(§ion_content).map_err(|mut e| {
125 if let Some(off) = e.byte_offset {
126 e.byte_offset = Some(body_byte_in_file + sec_start_in_body + off);
127 }
128 if e.byte_offset.is_some() {
129 e.message = format!("component {name:?}: {}", e.message);
130 }
131 e
132 })?;
133 let meta = meta_map.remove(&name).unwrap_or_default();
134 components.insert(name, ComponentDef { nodes, meta });
135 }
136
137 Ok(ComponentFile { components })
138}
139
140pub(crate) fn split_frontmatter_parts(content: &str) -> (Option<&str>, &str, usize) {
141 let trimmed = content.trim_start();
142 if !trimmed.starts_with("+++") {
143 return (None, content, 0);
144 }
145 let after_open = &trimmed[3..];
146 if let Some(close_pos) = after_open.find("\n+++") {
147 let raw_toml = &after_open[..close_pos];
148 let rest = &after_open[close_pos + 4..];
149 let body_start = subslice_byte_offset(content, rest);
150 (Some(raw_toml), rest, body_start)
151 } else {
152 (None, content, 0)
153 }
154}
155
156pub(crate) fn split_component_body_sections(body: &str) -> Vec<(String, String, usize)> {
157 let mut sections: Vec<(String, String, usize)> = Vec::new();
158 let mut current_name: Option<String> = None;
159 let mut current_lines: Vec<&str> = Vec::new();
160 let mut content_start_byte: Option<usize> = None;
161 let mut pending_content_start = 0usize;
162
163 let mut byte_pos = 0usize;
164 while byte_pos <= body.len() {
165 let line_start = byte_pos;
166 if line_start >= body.len() {
167 break;
168 }
169 let nl = body[line_start..].find('\n');
170 let line_end = nl.map(|n| line_start + n).unwrap_or(body.len());
171 let raw_line = &body[line_start..line_end];
172 byte_pos = if nl.is_some() {
173 line_end + 1
174 } else {
175 body.len()
176 };
177
178 let line_content = raw_line.strip_suffix('\r').unwrap_or(raw_line);
179 let trimmed = line_content.trim();
180
181 if let Some(name) = trimmed.strip_prefix("--- ") {
182 if let Some(prev) = current_name.take() {
183 let joined = current_lines.join("\n");
184 let start = content_start_byte.unwrap_or(pending_content_start);
185 sections.push((prev, joined, start));
186 current_lines.clear();
187 }
188 current_name = Some(name.trim().to_string());
189 content_start_byte = None;
190 pending_content_start = byte_pos;
191 } else if current_name.is_some() {
192 if content_start_byte.is_none() {
193 content_start_byte = Some(line_start);
194 }
195 current_lines.push(line_content);
196 }
197 }
198
199 if let Some(name) = current_name {
200 let joined = current_lines.join("\n");
201 let start = content_start_byte.unwrap_or(pending_content_start);
202 sections.push((name, joined, start));
203 }
204
205 sections
206}
207
208fn toml_value_to_expr(v: &toml::Value) -> String {
210 match v {
211 toml::Value::String(s) => format!("\"{}\"", s.replace('"', "\\\"")),
212 toml::Value::Integer(i) => i.to_string(),
213 toml::Value::Float(f) => f.to_string(),
214 toml::Value::Boolean(b) => b.to_string(),
215 _ => "\"\"".to_string(),
216 }
217}
218
219#[tracing::instrument(skip(template), fields(len = template.len()))]
220pub fn parse_template(template: &str) -> Result<Vec<Node>, String> {
221 parse_template_raw(template).map_err(|e| e.message)
222}
223
224pub(crate) fn parse_template_raw(template: &str) -> Result<Vec<Node>, RawParseError> {
225 if is_jsx_mode(template) {
226 parse_jsx_template(template)
227 } else {
228 let dec = crate::preprocess::strip_indent_decorators(template);
229 let lines = collect_lines(&dec.body);
230 let (mut nodes, _) = parse_nodes(&lines, 0, 0);
231 crate::preprocess::expand_class_aliases_in_nodes(&mut nodes, &dec.class_aliases);
232 Ok(nodes)
233 }
234}
235
236fn is_jsx_mode(template: &str) -> bool {
239 for line in template.lines() {
240 let t = line.trim();
241 if t.is_empty() || t.starts_with('#') || t.starts_with("$:") {
242 continue;
243 }
244 return t.starts_with('<');
245 }
246 false
247}
248
249fn strip_structural_indent(s: &str, n: usize) -> &str {
252 for (count, (byte_pos, ch)) in s.char_indices().enumerate() {
253 if count >= n || ch != ' ' {
254 return &s[byte_pos..];
255 }
256 }
257 ""
259}
260
261fn collect_lines(template: &str) -> Vec<(usize, String)> {
262 let source_lines: Vec<&str> = template.lines().collect();
263 let mut raw: Vec<(usize, String)> = Vec::new();
264 let mut i = 0;
265
266 while i < source_lines.len() {
267 let line = source_lines[i];
268 let trimmed = line.trim_start();
269 let indent = line.len() - trimmed.len();
270 i += 1;
271
272 if trimmed.is_empty() || trimmed.starts_with('#') {
273 continue;
274 }
275
276 if trimmed.starts_with('"') && !string_is_closed(trimmed) {
282 let mut combined = trimmed.to_string();
283 while i < source_lines.len() {
284 combined.push('\n');
285 combined.push_str(strip_structural_indent(source_lines[i], indent));
286 i += 1;
287 if string_is_closed(&combined) {
288 break;
289 }
290 }
291 raw.push((indent, combined));
292 } else {
293 raw.push((indent, trimmed.to_string()));
294 }
295 }
296
297 let min_indent = raw.iter().map(|(i, _)| *i).min().unwrap_or(0);
299 if min_indent == 0 {
300 return raw;
301 }
302 raw.into_iter().map(|(i, l)| (i - min_indent, l)).collect()
303}
304
305fn string_is_closed(s: &str) -> bool {
308 if !s.starts_with('"') {
309 return false;
310 }
311 let mut escaped = false;
312 let mut closes = 0usize;
313 for ch in s.chars().skip(1) {
314 if escaped {
315 escaped = false;
316 continue;
317 }
318 if ch == '\\' {
319 escaped = true;
320 continue;
321 }
322 if ch == '"' {
323 closes += 1;
324 }
325 }
326 closes > 0
327}
328
329fn parse_nodes(
330 lines: &[(usize, String)],
331 start: usize,
332 expected_indent: usize,
333) -> (Vec<Node>, usize) {
334 let mut nodes = Vec::new();
335 let mut i = start;
336
337 while i < lines.len() {
338 let (indent, line) = &lines[i];
339
340 if *indent < expected_indent {
341 break;
342 }
343 if *indent > expected_indent {
344 i += 1;
345 continue;
346 }
347
348 if line == "else" || line.starts_with("else if ") {
350 break;
351 }
352
353 if line.ends_with(" =>") || line == "_ =>" {
355 break;
356 }
357
358 if let Some(embed) = try_parse_embed(line) {
359 nodes.push(Node::Embed(embed));
360 i += 1;
361 continue;
362 }
363
364 if let Some(mut inc) = try_parse_include(line) {
366 i += 1;
367 let (slot, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
368 let child_indent = lines[i].0;
369 parse_nodes(lines, i, child_indent)
370 } else {
371 (vec![], i)
372 };
373 i = next_i;
374 inc.slot = slot;
375 nodes.push(Node::Include(inc));
376 continue;
377 }
378
379 if let Some(decl) = try_parse_let_decl(line) {
381 nodes.push(Node::LetDecl(decl));
382 i += 1;
383 continue;
384 }
385
386 if let Some(expr) = try_parse_match(line) {
388 i += 1;
389 let (arms, next_i) = parse_match_arms(lines, i, expected_indent);
390 i = next_i;
391 nodes.push(Node::Match(MatchBlock { expr, arms }));
392 continue;
393 }
394
395 if try_parse_if(line).is_some() {
397 let (node, next_i) = parse_if_node(lines, i, expected_indent);
398 i = next_i;
399 nodes.push(node);
400 continue;
401 }
402
403 i += 1;
404
405 let (children, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
407 let child_indent = lines[i].0;
408 parse_nodes(lines, i, child_indent)
409 } else {
410 (vec![], i)
411 };
412 i = next_i;
413
414 if let Some((pattern, iterator)) = try_parse_for(line) {
415 nodes.push(Node::For(ForBlock {
416 pattern,
417 iterator,
418 body: children,
419 }));
420 } else if line.starts_with('"') {
421 let parts = parse_text_template(line);
422 nodes.push(Node::Text(parts));
423 } else if is_raw_expr(line) {
424 nodes.push(Node::RawText(line[1..line.len() - 1].trim().to_string()));
426 } else {
427 let element = parse_element_line(line, children);
428 nodes.push(Node::Element(element));
429 }
430 }
431
432 (nodes, i)
433}
434
435fn parse_if_node(lines: &[(usize, String)], i: usize, expected_indent: usize) -> (Node, usize) {
436 let line = &lines[i].1;
437 let condition = try_parse_if(line).unwrap_or_default();
438 let mut i = i + 1;
439
440 let (then_children, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
441 let child_indent = lines[i].0;
442 parse_nodes(lines, i, child_indent)
443 } else {
444 (vec![], i)
445 };
446 i = next_i;
447
448 let else_children = if i < lines.len() && lines[i].0 == expected_indent {
449 let else_line = &lines[i].1;
450 if else_line == "else" {
451 i += 1;
452 if i < lines.len() && lines[i].0 > expected_indent {
453 let else_indent = lines[i].0;
454 let (else_nodes, next_i) = parse_nodes(lines, i, else_indent);
455 i = next_i;
456 Some(else_nodes)
457 } else {
458 Some(vec![])
459 }
460 } else if else_line.starts_with("else if ") {
461 let rewritten = else_line
462 .strip_prefix("else ")
463 .unwrap_or(else_line)
464 .to_string();
465 let mut patched = lines.to_vec();
466 patched[i].1 = rewritten;
467 let (else_if_node, next_i) = parse_if_node(&patched, i, expected_indent);
468 i = next_i;
469 Some(vec![else_if_node])
470 } else {
471 None
472 }
473 } else {
474 None
475 };
476
477 (
478 Node::If(IfBlock {
479 condition,
480 then_children,
481 else_children,
482 }),
483 i,
484 )
485}
486
487fn parse_match_arms(
488 lines: &[(usize, String)],
489 start: usize,
490 expected_indent: usize,
491) -> (Vec<MatchArm>, usize) {
492 let mut arms = Vec::new();
493 let mut i = start;
494
495 while i < lines.len() {
496 let (indent, line) = &lines[i];
497 if *indent < expected_indent {
498 break;
499 }
500 if *indent > expected_indent {
501 i += 1;
502 continue;
503 }
504
505 if let Some(pattern) = try_parse_match_arm(line) {
506 i += 1;
507 let (body, next_i) = if i < lines.len() && lines[i].0 > expected_indent {
508 let body_indent = lines[i].0;
509 parse_nodes(lines, i, body_indent)
510 } else {
511 (vec![], i)
512 };
513 i = next_i;
514 arms.push(MatchArm { pattern, body });
515 } else {
516 break;
517 }
518 }
519
520 (arms, i)
521}
522
523fn try_parse_include(line: &str) -> Option<IncludeNode> {
526 let rest = line.strip_prefix("include ")?;
527 let (path, props_str) = match rest.find(' ') {
529 Some(pos) => (rest[..pos].trim().to_string(), rest[pos + 1..].trim()),
530 None => (rest.trim().to_string(), ""),
531 };
532 if path.is_empty() {
533 return None;
534 }
535 let props = parse_props(props_str);
536 Some(IncludeNode {
537 path,
538 props,
539 slot: vec![],
540 })
541}
542
543fn try_parse_embed(line: &str) -> Option<EmbedNode> {
544 let rest = line.strip_prefix("embed ")?;
545 let (src, props_str) = match rest.find(' ') {
546 Some(pos) => (rest[..pos].trim().to_string(), rest[pos + 1..].trim()),
547 None => (rest.trim().to_string(), ""),
548 };
549 if src.is_empty() {
550 return None;
551 }
552 let mut props = parse_props(props_str);
553 let adapter = take_literal_prop(&mut props, "adapter");
554 Some(EmbedNode {
555 src,
556 adapter,
557 props,
558 })
559}
560
561fn take_literal_prop(props: &mut Vec<(String, String)>, key: &str) -> Option<String> {
562 let pos = props.iter().position(|(k, _)| k == key)?;
563 let (_, value) = props.remove(pos);
564 Some(unquote_expr_string(&value).unwrap_or(value))
565}
566
567fn unquote_expr_string(value: &str) -> Option<String> {
568 if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') {
569 Some(value[1..value.len() - 1].replace("\\\"", "\""))
570 } else {
571 None
572 }
573}
574
575fn parse_props(s: &str) -> Vec<(String, String)> {
576 let mut props = Vec::new();
577 let mut remaining = s.trim();
578
579 while !remaining.is_empty() {
580 let eq_pos = match remaining.find('=') {
582 Some(p) => p,
583 None => break,
584 };
585 let key = remaining[..eq_pos].trim().to_string();
586 if key.is_empty() || key.contains(' ') {
587 break;
588 }
589 remaining = remaining[eq_pos + 1..].trim_start();
590
591 let (expr_str, rest) = extract_prop_value(remaining);
593 props.push((key, expr_str));
594 remaining = rest.trim_start();
595 }
596
597 props
598}
599
600fn extract_prop_value(s: &str) -> (String, &str) {
606 if s.is_empty() {
607 return (String::new(), s);
608 }
609
610 if s.starts_with('"') || s.starts_with('\'') {
611 let quote = s.as_bytes()[0];
612 let mut i = 1;
613 let mut escaped = false;
614 while i < s.len() {
615 let byte = s.as_bytes()[i];
616 if escaped {
617 escaped = false;
618 } else if byte == b'\\' {
619 escaped = true;
620 } else if byte == quote {
621 let content = &s[1..i];
622 let escaped_content = content.replace('\\', "\\\\").replace('"', "\\\"");
623 let expr = format!("\"{}\"", escaped_content);
624 let rest = if i < s.len() { &s[i + 1..] } else { "" };
625 return (expr, rest);
626 }
627 i += 1;
628 }
629
630 let content = &s[1..];
631 let escaped_content = content.replace('\\', "\\\\").replace('"', "\\\"");
632 let expr = format!("\"{}\"", escaped_content);
633 return (expr, "");
634 }
635
636 if s.starts_with('{') {
637 let mut depth = 0usize;
638 for (i, c) in s.char_indices() {
639 match c {
640 '{' => depth += 1,
641 '}' => {
642 depth -= 1;
643 if depth == 0 {
644 let expr = s[1..i].trim().to_string();
645 return (expr, &s[i + 1..]);
646 }
647 }
648 _ => {}
649 }
650 }
651 return (s.to_string(), "");
652 }
653
654 let end = s.find(' ').unwrap_or(s.len());
656 (s[..end].to_string(), &s[end..])
657}
658
659fn try_parse_if(line: &str) -> Option<String> {
662 let rest = line.strip_prefix("if ")?;
663 Some(extract_braced(rest.trim()).unwrap_or_else(|| rest.trim().to_string()))
664}
665
666fn try_parse_for(line: &str) -> Option<(String, String)> {
667 let rest = line.strip_prefix("for ")?;
668 let in_pos = rest.find(" in ")?;
669 let pattern = rest[..in_pos].trim().to_string();
670 let after_in = rest[in_pos + 4..].trim();
671 let iterator = extract_braced(after_in).unwrap_or_else(|| after_in.to_string());
672 Some((pattern, iterator))
673}
674
675fn try_parse_match(line: &str) -> Option<String> {
676 let rest = line.strip_prefix("match ")?;
677 Some(extract_braced(rest.trim()).unwrap_or_else(|| rest.trim().to_string()))
678}
679
680fn try_parse_match_arm(line: &str) -> Option<String> {
681 let pattern = line.strip_suffix(" =>")?;
682 let pattern = pattern.trim();
683 if pattern.starts_with('{') && pattern.ends_with('}') {
684 Some(pattern[1..pattern.len() - 1].trim().to_string())
685 } else {
686 Some(pattern.to_string())
687 }
688}
689
690fn try_parse_let_decl(line: &str) -> Option<LetDecl> {
691 let (rest, is_default) = if let Some(r) = line.strip_prefix("$: default ") {
692 (r, true)
693 } else if let Some(r) = line.strip_prefix("$: let ") {
694 (r, false)
695 } else {
696 return None;
697 };
698 let eq_pos = rest.find('=')?;
699 let name = rest[..eq_pos].trim().to_string();
700 let expr_str = rest[eq_pos + 1..].trim();
701 let expr = extract_braced(expr_str).unwrap_or_else(|| expr_str.to_string());
702 Some(LetDecl {
703 name,
704 expr,
705 is_default,
706 })
707}
708
709fn is_raw_expr(line: &str) -> bool {
710 line.starts_with('{') && line.ends_with('}') && {
711 let inner = &line[1..line.len() - 1];
712 !inner.contains('"')
713 }
714}
715
716fn extract_braced(s: &str) -> Option<String> {
717 if !s.starts_with('{') {
718 return None;
719 }
720 let mut depth = 0usize;
721 for (i, c) in s.char_indices() {
722 match c {
723 '{' => depth += 1,
724 '}' => {
725 depth -= 1;
726 if depth == 0 {
727 return Some(s[1..i].trim().to_string());
728 }
729 }
730 _ => {}
731 }
732 }
733 None
734}
735
736fn parse_element_line(line: &str, children: Vec<Node>) -> Element {
737 let tokens = tokenize_line(line);
738 if tokens.is_empty() {
739 return Element {
740 tag: "div".to_string(),
741 id: None,
742 classes: vec![],
743 conditional_classes: vec![],
744 event_handlers: vec![],
745 bindings: vec![],
746 animations: vec![],
747 children,
748 };
749 }
750
751 let tag = tokens[0].clone();
752 let mut children = children;
753 let inline_text = tokens
754 .last()
755 .filter(|token| is_inline_text_token(token))
756 .cloned();
757 let parse_limit = if inline_text.is_some() {
758 tokens.len().saturating_sub(1)
759 } else {
760 tokens.len()
761 };
762 if let Some(text) = inline_text {
763 children.insert(0, Node::Text(parse_text_template(&text)));
764 }
765
766 let mut id = None;
767 let mut classes = Vec::new();
768 let mut conditional_classes = Vec::new();
769 let mut event_handlers = Vec::new();
770 let mut bindings = Vec::new();
771 let mut animations = Vec::new();
772
773 for token in &tokens[1..parse_limit] {
774 if let Some(rest) = token.strip_prefix('@') {
775 if let Some(eq_pos) = rest.find('=') {
776 let event_part = &rest[..eq_pos];
777 let handler = strip_optional_quotes(&rest[eq_pos + 1..]).to_string();
778 let event = event_part.split('|').next().unwrap_or("").to_string();
779 let modifiers: Vec<String> = event_part
780 .split('|')
781 .skip(1)
782 .map(|s| s.to_string())
783 .collect();
784 event_handlers.push(EventHandler {
785 event,
786 modifiers,
787 handler,
788 });
789 }
790 } else if let Some(rest) = token.strip_prefix("when:") {
791 if let Some((condition, raw_classes)) = parse_when_attribute_suffix(rest) {
792 let classes_src = strip_optional_quotes(raw_classes.trim());
793 for class in classes_src.split_whitespace() {
794 if class.is_empty() {
795 continue;
796 }
797 conditional_classes.push(ConditionalClass {
798 class: class.to_string(),
799 condition: condition.clone(),
800 });
801 }
802 }
803 } else if let Some(rest) = token.strip_prefix("class:") {
804 if let Some(eq_pos) = rest.find('=') {
805 let class = rest[..eq_pos].to_string();
806 let cond_str = rest[eq_pos + 1..].trim();
807 let condition = if cond_str.starts_with('{') && cond_str.ends_with('}') {
808 cond_str[1..cond_str.len() - 1].trim().to_string()
809 } else {
810 cond_str.to_string()
811 };
812 conditional_classes.push(ConditionalClass { class, condition });
813 }
814 } else if let Some(rest) = token.strip_prefix("bind:") {
815 if let Some(eq_pos) = rest.find('=') {
816 let prop = rest[..eq_pos].to_string();
817 let value = rest[eq_pos + 1..]
818 .trim_matches(|c| c == '{' || c == '}')
819 .to_string();
820 bindings.push(Binding { prop, value });
821 }
822 } else if let Some(rest) = token.strip_prefix("animate:") {
823 if let Some(eq_pos) = rest.find('=') {
825 let property = rest[..eq_pos].to_string();
826 let value_str = rest[eq_pos + 1..]
827 .trim_matches(|c| c == '{' || c == '}')
828 .trim()
829 .to_string();
830 let parts: Vec<&str> = value_str.split_whitespace().collect();
831 let duration_expr = parts.first().unwrap_or(&"300ms").to_string();
832 let easing = parts.get(1).unwrap_or(&"linear").to_string();
833 let repeat = parts.get(2).map(|s| *s == "repeat").unwrap_or(false);
834 animations.push(AnimationSpec {
835 property,
836 duration_expr,
837 easing,
838 repeat,
839 });
840 }
841 } else if let Some(rest) = token.strip_prefix('#') {
842 if !rest.is_empty() {
843 id = Some(rest.to_string());
844 }
845 } else if token.contains('=') {
846 let eq_pos = token.find('=').unwrap();
848 let key = &token[..eq_pos];
849 let valid_key = !key.is_empty()
850 && key
851 .chars()
852 .all(|c| c.is_alphanumeric() || c == '-' || c == '_');
853 if valid_key {
854 let raw = token[eq_pos + 1..].trim();
855 let unquoted = if raw.len() >= 2
856 && ((raw.starts_with('"') && raw.ends_with('"'))
857 || (raw.starts_with('\'') && raw.ends_with('\'')))
858 {
859 &raw[1..raw.len() - 1]
860 } else {
861 raw
862 };
863 if key == "class" {
864 for cls in unquoted.split_whitespace() {
866 classes.push(cls.to_string());
867 }
868 } else if key == "id" {
869 id = Some(unquoted.to_string());
870 } else {
871 let expr = if raw.starts_with('{') && raw.ends_with('}') {
872 raw[1..raw.len() - 1].trim().to_string()
873 } else {
874 format!("\"{}\"", unquoted)
875 };
876 bindings.push(Binding {
877 prop: key.to_string(),
878 value: expr,
879 });
880 }
881 } else {
882 classes.push(token.clone());
883 }
884 } else if matches!(
885 token.as_str(),
886 "checked"
887 | "disabled"
888 | "hidden"
889 | "required"
890 | "readonly"
891 | "multiple"
892 | "selected"
893 | "autofocus"
894 | "open"
895 ) {
896 bindings.push(Binding {
898 prop: token.clone(),
899 value: "\"\"".to_string(),
900 });
901 } else {
902 classes.push(token.clone());
903 }
904 }
905
906 Element {
907 tag,
908 id,
909 classes,
910 conditional_classes,
911 event_handlers,
912 bindings,
913 animations,
914 children,
915 }
916}
917
918fn is_inline_text_token(token: &str) -> bool {
919 token.len() >= 2 && token.starts_with('"') && token.ends_with('"')
920}
921
922fn strip_optional_quotes(s: &str) -> &str {
923 if s.len() >= 2
924 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
925 {
926 &s[1..s.len() - 1]
927 } else {
928 s
929 }
930}
931
932pub fn parse_when_attribute_suffix(src: &str) -> Option<(String, String)> {
942 let s = src.trim();
943 if s.is_empty() {
944 return None;
945 }
946 if s.starts_with('{') {
947 let mut depth = 0usize;
948 for (i, c) in s.char_indices() {
949 match c {
950 '{' => depth += 1,
951 '}' => {
952 depth -= 1;
953 if depth == 0 {
954 let cond = s[1..i].trim().to_string();
955 let mut tail = s[i + 1..].trim_start();
956 tail = tail.strip_prefix('=')?;
957 return Some((cond, tail.trim().to_string()));
958 }
959 }
960 _ => {}
961 }
962 }
963 return None;
964 }
965 let eq_pos = s.find('=')?;
966 let cond = s[..eq_pos].trim().to_string();
967 if cond.is_empty() {
968 return None;
969 }
970 Some((cond, s[eq_pos + 1..].trim().to_string()))
971}
972
973fn tokenize_line(line: &str) -> Vec<String> {
974 let line = normalize_fullwidth_braces(line);
975 let mut tokens = Vec::new();
976 let mut current = String::new();
977 let mut bracket_depth: usize = 0;
978 let mut brace_depth: usize = 0;
979 let mut in_string = false;
980 let mut string_char = ' ';
981
982 for ch in line.chars() {
983 match ch {
984 '[' if !in_string && brace_depth == 0 => {
985 bracket_depth += 1;
986 current.push(ch);
987 }
988 ']' if !in_string && brace_depth == 0 => {
989 bracket_depth = bracket_depth.saturating_sub(1);
990 current.push(ch);
991 }
992 '{' if !in_string && bracket_depth == 0 => {
993 brace_depth += 1;
994 current.push(ch);
995 }
996 '}' if !in_string && bracket_depth == 0 => {
997 brace_depth = brace_depth.saturating_sub(1);
998 current.push(ch);
999 }
1000 '\'' | '"' => {
1001 if in_string && ch == string_char {
1002 in_string = false;
1003 } else if !in_string {
1004 in_string = true;
1005 string_char = ch;
1006 }
1007 current.push(ch);
1008 }
1009 ' ' | '\t' if bracket_depth == 0 && brace_depth == 0 && !in_string => {
1010 if !current.is_empty() {
1011 tokens.push(current.clone());
1012 current.clear();
1013 }
1014 }
1015 _ => current.push(ch),
1016 }
1017 }
1018
1019 if !current.is_empty() {
1020 tokens.push(current);
1021 }
1022 tokens
1023}
1024
1025pub fn unescape_crepus_text_literal(s: &str) -> String {
1029 let mut out = String::with_capacity(s.len());
1030 let mut chars = s.chars();
1031 while let Some(c) = chars.next() {
1032 if c != '\\' {
1033 out.push(c);
1034 continue;
1035 }
1036 match chars.next() {
1037 Some('n') => out.push('\n'),
1038 Some('r') => out.push('\r'),
1039 Some('t') => out.push('\t'),
1040 Some('\\') => out.push('\\'),
1041 Some('"') => out.push('"'),
1042 Some('\'') => out.push('\''),
1043 Some(other) => {
1044 out.push('\\');
1045 out.push(other);
1046 }
1047 None => out.push('\\'),
1048 }
1049 }
1050 out
1051}
1052
1053fn parse_text_template(line: &str) -> Vec<TextPart> {
1054 let content = if line.starts_with('"') && line.ends_with('"') && line.len() >= 2 {
1055 &line[1..line.len() - 1]
1056 } else {
1057 line
1058 };
1059
1060 let mut parts = Vec::new();
1061 let mut literal = String::new();
1062 let mut chars = content.chars().peekable();
1063
1064 while let Some(ch) = chars.next() {
1065 if ch == '{' {
1066 if !literal.is_empty() {
1067 parts.push(TextPart::Literal(unescape_crepus_text_literal(&literal)));
1068 literal.clear();
1069 }
1070 let mut expr = String::new();
1071 let mut depth = 1usize;
1072 for ec in chars.by_ref() {
1073 match ec {
1074 '{' => {
1075 depth += 1;
1076 expr.push(ec);
1077 }
1078 '}' => {
1079 depth -= 1;
1080 if depth == 0 {
1081 break;
1082 }
1083 expr.push(ec);
1084 }
1085 _ => expr.push(ec),
1086 }
1087 }
1088 parts.push(TextPart::Expr(expr.trim().to_string()));
1089 } else {
1090 literal.push(ch);
1091 }
1092 }
1093
1094 if !literal.is_empty() {
1095 parts.push(TextPart::Literal(unescape_crepus_text_literal(&literal)));
1096 }
1097
1098 parts
1099}
1100
1101struct JsxAttr {
1128 key: String,
1129 value: JsxAttrValue,
1130}
1131
1132enum JsxAttrValue {
1133 Bool(bool),
1134 Str(String),
1135 Expr(String),
1136}
1137
1138impl JsxAttr {
1139 fn as_str(&self) -> Option<&str> {
1140 if let JsxAttrValue::Str(s) = &self.value {
1141 Some(s)
1142 } else {
1143 None
1144 }
1145 }
1146
1147 fn as_expr(&self) -> Option<String> {
1149 match &self.value {
1150 JsxAttrValue::Expr(e) => Some(e.clone()),
1151 JsxAttrValue::Str(s) => Some(format!("\"{}\"", s.replace('"', "\\\""))),
1152 JsxAttrValue::Bool(b) => Some(b.to_string()),
1153 }
1154 }
1155}
1156
1157fn normalize_jsx_mapped(s: &str) -> (String, Vec<usize>) {
1160 let mut norm = String::with_capacity(s.len());
1161 let mut map: Vec<usize> = Vec::with_capacity(s.len());
1162 for (orig_b, ch) in s.char_indices() {
1163 match ch {
1164 '\u{FF5B}' => {
1165 norm.push('{');
1166 map.push(orig_b);
1167 }
1168 '\u{FF5D}' => {
1169 norm.push('}');
1170 map.push(orig_b);
1171 }
1172 c => {
1173 let mut buf = [0u8; 4];
1174 let enc = c.encode_utf8(&mut buf);
1175 for _ in 0..enc.len() {
1176 map.push(orig_b);
1177 }
1178 norm.push_str(enc);
1179 }
1180 }
1181 }
1182 debug_assert_eq!(norm.len(), map.len());
1183 (norm, map)
1184}
1185
1186fn map_jsx_offset(map: &[usize], off: usize) -> usize {
1187 map.get(off).copied().unwrap_or(off)
1188}
1189
1190#[inline]
1191fn jsx_err(norm_root: &str, at: &str, message: impl Into<String>) -> RawParseError {
1192 RawParseError {
1193 message: message.into(),
1194 byte_offset: Some(subslice_byte_offset(norm_root, at)),
1195 }
1196}
1197
1198fn parse_jsx_template(src: &str) -> Result<Vec<Node>, RawParseError> {
1199 let (norm, map) = normalize_jsx_mapped(src);
1200 let root = norm.as_str();
1201 match parse_jsx_nodes(root, root) {
1202 Ok((nodes, _)) => Ok(nodes),
1203 Err(mut err) => {
1204 if let Some(off) = err.byte_offset.take() {
1205 err.byte_offset = Some(map_jsx_offset(&map, off));
1206 }
1207 Err(err)
1208 }
1209 }
1210}
1211
1212fn parse_jsx_nodes<'a>(
1213 norm_root: &'a str,
1214 src: &'a str,
1215) -> Result<(Vec<Node>, &'a str), RawParseError> {
1216 let mut nodes = Vec::new();
1217 let mut rest = src;
1218
1219 loop {
1220 let t = rest.trim_start();
1221
1222 if t.is_empty() {
1223 rest = t;
1224 break;
1225 }
1226 if t.starts_with("</") || t.starts_with("<else") {
1227 rest = t;
1228 break;
1229 }
1230 if t.starts_with("$:") {
1231 let end = t.find('\n').unwrap_or(t.len());
1232 let line = t[..end].trim();
1233 rest = &t[end..];
1234 if let Some(decl) = try_parse_let_decl(line) {
1235 nodes.push(Node::LetDecl(decl));
1236 }
1237 continue;
1238 }
1239 if t.starts_with('<') {
1240 rest = t;
1241 let (node, next) = parse_jsx_tag(norm_root, rest)?;
1242 nodes.push(node);
1243 rest = next;
1244 continue;
1245 }
1246 if t.starts_with('{') {
1247 rest = t;
1248 let (expr, next) = jsx_brace_expr(norm_root, rest)?;
1249 nodes.push(Node::RawText(expr));
1250 rest = next;
1251 continue;
1252 }
1253 let prev_len = rest.len();
1254 let (node_opt, next) = jsx_text_node(rest);
1255 if let Some(node) = node_opt {
1256 nodes.push(node);
1257 }
1258 rest = next;
1259 if rest.len() == prev_len {
1260 let skip = rest
1261 .char_indices()
1262 .nth(1)
1263 .map(|(i, _)| i)
1264 .unwrap_or(rest.len());
1265 rest = &rest[skip..];
1266 }
1267 }
1268
1269 Ok((nodes, rest))
1270}
1271
1272fn parse_jsx_tag<'a>(norm_root: &'a str, src: &'a str) -> Result<(Node, &'a str), RawParseError> {
1273 let src = src.trim_start();
1274 let after_lt = &src[1..];
1275 let name_end = after_lt
1276 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
1277 .unwrap_or(after_lt.len());
1278 let tag = &after_lt[..name_end];
1279 let rest = after_lt[name_end..].trim_start();
1280
1281 let (attrs, after_gt, self_closing) = jsx_parse_attrs(norm_root, rest)?;
1282
1283 match tag {
1284 "if" => parse_jsx_if(norm_root, attrs, after_gt),
1285 "else" | "else-if" => Err(jsx_err(
1286 norm_root,
1287 src,
1288 format!("<{tag}> encountered outside <if>"),
1289 )),
1290 "for" => parse_jsx_for(norm_root, attrs, after_gt),
1291 "match" => parse_jsx_match(norm_root, attrs, after_gt),
1292 "island" | "crepus-island" if self_closing => Ok((jsx_build_embed(attrs), after_gt)),
1293 "include" if self_closing => Ok((jsx_build_include(attrs, vec![]), after_gt)),
1294 "include" => {
1295 let (slot, rest) = parse_jsx_nodes(norm_root, after_gt)?;
1296 let rest = jsx_close(norm_root, rest, "include")?;
1297 Ok((jsx_build_include(attrs, slot), rest))
1298 }
1299 "let" => Ok((Node::LetDecl(jsx_build_let(attrs, false)), after_gt)),
1300 "let-default" => Ok((Node::LetDecl(jsx_build_let(attrs, true)), after_gt)),
1301 _ if self_closing => Ok((
1302 Node::Element(jsx_build_element(tag, attrs, vec![])),
1303 after_gt,
1304 )),
1305 _ => {
1306 let (children, rest) = parse_jsx_nodes(norm_root, after_gt)?;
1307 let rest = jsx_close(norm_root, rest, tag)?;
1308 Ok((Node::Element(jsx_build_element(tag, attrs, children)), rest))
1309 }
1310 }
1311}
1312
1313fn parse_jsx_if<'a>(
1314 norm_root: &'a str,
1315 attrs: Vec<JsxAttr>,
1316 children_src: &'a str,
1317) -> Result<(Node, &'a str), RawParseError> {
1318 let condition = attrs
1319 .iter()
1320 .find(|a| matches!(a.key.as_str(), "condition" | "test" | "cond"))
1321 .and_then(|a| a.as_expr())
1322 .unwrap_or_default();
1323
1324 let (then_children, rest) = parse_jsx_nodes(norm_root, children_src)?;
1325 let rest = rest.trim_start();
1326
1327 let (else_children, rest) = if rest.starts_with("<else-if") {
1328 let after_name = rest.strip_prefix("<else-if").unwrap_or("").trim_start();
1329 let (ei_attrs, ei_body, _) = jsx_parse_attrs(norm_root, after_name)?;
1330 let (nested, next) = parse_jsx_if(norm_root, ei_attrs, ei_body)?;
1331 (Some(vec![nested]), next)
1332 } else if rest.starts_with("<else") {
1333 let after_name = rest.strip_prefix("<else").unwrap_or("").trim_start();
1334 let (_, else_body, self_closing) = jsx_parse_attrs(norm_root, after_name)?;
1335 if self_closing {
1336 (Some(vec![]), else_body)
1337 } else {
1338 let (else_nodes, after_nodes) = parse_jsx_nodes(norm_root, else_body)?;
1339 let after_close = jsx_close(norm_root, after_nodes, "else")?;
1340 (Some(else_nodes), after_close)
1341 }
1342 } else {
1343 (None, rest)
1344 };
1345
1346 let rest = jsx_close(norm_root, rest, "if")?;
1347 Ok((
1348 Node::If(IfBlock {
1349 condition,
1350 then_children,
1351 else_children,
1352 }),
1353 rest,
1354 ))
1355}
1356
1357fn parse_jsx_for<'a>(
1358 norm_root: &'a str,
1359 attrs: Vec<JsxAttr>,
1360 children_src: &'a str,
1361) -> Result<(Node, &'a str), RawParseError> {
1362 let pattern = attrs
1363 .iter()
1364 .find(|a| matches!(a.key.as_str(), "let" | "var"))
1365 .and_then(|a| a.as_str())
1366 .unwrap_or("")
1367 .to_string();
1368 let iterator = attrs
1369 .iter()
1370 .find(|a| a.key == "in")
1371 .and_then(|a| a.as_expr())
1372 .unwrap_or_default();
1373
1374 let (body, rest) = parse_jsx_nodes(norm_root, children_src)?;
1375 let rest = jsx_close(norm_root, rest, "for")?;
1376 Ok((
1377 Node::For(ForBlock {
1378 pattern,
1379 iterator,
1380 body,
1381 }),
1382 rest,
1383 ))
1384}
1385
1386fn parse_jsx_match<'a>(
1387 norm_root: &'a str,
1388 attrs: Vec<JsxAttr>,
1389 children_src: &'a str,
1390) -> Result<(Node, &'a str), RawParseError> {
1391 let expr = attrs
1392 .iter()
1393 .find(|a| matches!(a.key.as_str(), "on" | "value"))
1394 .and_then(|a| a.as_expr())
1395 .unwrap_or_default();
1396
1397 let mut arms = Vec::new();
1398 let mut rest = children_src.trim_start();
1399
1400 while rest.starts_with("<case") {
1401 let after_name = &rest["<case".len()..].trim_start();
1402 let (case_attrs, case_body, self_closing) = jsx_parse_attrs(norm_root, after_name)?;
1403 let pattern = case_attrs
1404 .iter()
1405 .find(|a| matches!(a.key.as_str(), "pattern" | "match" | "when"))
1406 .and_then(|a| match &a.value {
1407 JsxAttrValue::Str(s) => Some(s.clone()),
1408 JsxAttrValue::Expr(e) => Some(e.clone()),
1409 JsxAttrValue::Bool(_) => None,
1410 })
1411 .unwrap_or_else(|| "_".to_string());
1412 let (body, after_body): (Vec<Node>, &str) = if self_closing {
1413 (vec![], case_body)
1414 } else {
1415 let (b, r) = parse_jsx_nodes(norm_root, case_body)?;
1416 let r = jsx_close(norm_root, r, "case")?;
1417 (b, r)
1418 };
1419 arms.push(MatchArm { pattern, body });
1420 rest = after_body.trim_start();
1421 }
1422
1423 let rest = jsx_close(norm_root, rest, "match")?;
1424 Ok((Node::Match(MatchBlock { expr, arms }), rest))
1425}
1426
1427fn jsx_build_element(tag: &str, attrs: Vec<JsxAttr>, children: Vec<Node>) -> Element {
1430 let mut id = None;
1431 let mut classes = Vec::new();
1432 let mut conditional_classes = Vec::new();
1433 let mut event_handlers = Vec::new();
1434 let mut bindings = Vec::new();
1435 let mut animations = Vec::new();
1436
1437 for attr in attrs {
1438 let key = &attr.key;
1439
1440 if key == "class" || key == "className" {
1442 match &attr.value {
1443 JsxAttrValue::Str(s) => {
1444 classes.extend(s.split_whitespace().map(|c| c.to_string()));
1445 }
1446 JsxAttrValue::Expr(e) => {
1447 classes.push(format!("{{{}}}", e));
1449 }
1450 JsxAttrValue::Bool(_) => {}
1451 }
1452 continue;
1453 }
1454
1455 if key == "id" {
1456 if let Some(value) = attr.as_expr() {
1457 let trimmed = value.trim();
1458 if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
1459 id = Some(trimmed[1..trimmed.len() - 1].to_string());
1460 }
1461 }
1462 continue;
1463 }
1464
1465 if let Some(class_name) = key.strip_prefix("class:") {
1467 conditional_classes.push(ConditionalClass {
1468 class: class_name.to_string(),
1469 condition: attr.as_expr().unwrap_or_default(),
1470 });
1471 continue;
1472 }
1473
1474 if let Some(cond_src) = key.strip_prefix("when:") {
1476 let condition = if cond_src.starts_with('{') && cond_src.ends_with('}') {
1477 cond_src[1..cond_src.len() - 1].trim().to_string()
1478 } else {
1479 cond_src.trim().to_string()
1480 };
1481 if condition.is_empty() {
1482 continue;
1483 }
1484 match &attr.value {
1485 JsxAttrValue::Str(s) => {
1486 for class in s.split_whitespace() {
1487 if class.is_empty() {
1488 continue;
1489 }
1490 conditional_classes.push(ConditionalClass {
1491 class: class.to_string(),
1492 condition: condition.clone(),
1493 });
1494 }
1495 }
1496 JsxAttrValue::Expr(_) | JsxAttrValue::Bool(_) => {}
1497 }
1498 continue;
1499 }
1500
1501 if let Some(event_part) = key.strip_prefix('@') {
1503 let event = event_part.split('|').next().unwrap_or("").to_string();
1504 let modifiers = event_part
1505 .split('|')
1506 .skip(1)
1507 .map(|s| s.to_string())
1508 .collect();
1509 event_handlers.push(EventHandler {
1510 event,
1511 modifiers,
1512 handler: attr.as_expr().unwrap_or_default(),
1513 });
1514 continue;
1515 }
1516
1517 if key.starts_with("on") && key.len() > 2 {
1519 let rest = &key[2..];
1520 if rest.starts_with(|c: char| c.is_ascii_uppercase()) {
1521 let first = rest.chars().next().unwrap();
1522 let event = format!(
1523 "{}{}",
1524 first.to_ascii_lowercase(),
1525 &rest[first.len_utf8()..]
1526 );
1527 event_handlers.push(EventHandler {
1528 event,
1529 modifiers: vec![],
1530 handler: attr.as_expr().unwrap_or_default(),
1531 });
1532 continue;
1533 }
1534 }
1535
1536 if let Some(prop) = key.strip_prefix("animate:") {
1538 let val = attr.as_expr().unwrap_or_default();
1539 let parts: Vec<&str> = val.split_whitespace().collect();
1540 animations.push(AnimationSpec {
1541 property: prop.to_string(),
1542 duration_expr: parts.first().unwrap_or(&"300ms").to_string(),
1543 easing: parts.get(1).unwrap_or(&"linear").to_string(),
1544 repeat: parts.get(2).map(|s| *s == "repeat").unwrap_or(false),
1545 });
1546 continue;
1547 }
1548
1549 if let Some(prop) = key.strip_prefix("bind:") {
1551 bindings.push(Binding {
1552 prop: prop.to_string(),
1553 value: attr.as_expr().unwrap_or_default(),
1554 });
1555 continue;
1556 }
1557
1558 if let Some(value) = attr.as_expr() {
1560 bindings.push(Binding {
1561 prop: key.clone(),
1562 value,
1563 });
1564 }
1565 }
1566
1567 Element {
1568 tag: tag.to_string(),
1569 id,
1570 classes,
1571 conditional_classes,
1572 event_handlers,
1573 bindings,
1574 animations,
1575 children,
1576 }
1577}
1578
1579fn jsx_build_include(attrs: Vec<JsxAttr>, slot: Vec<Node>) -> Node {
1580 let path = attrs
1581 .iter()
1582 .find(|a| matches!(a.key.as_str(), "src" | "path"))
1583 .and_then(|a| a.as_str())
1584 .unwrap_or("")
1585 .to_string();
1586 let props = attrs
1587 .iter()
1588 .filter(|a| !matches!(a.key.as_str(), "src" | "path"))
1589 .filter_map(|a| a.as_expr().map(|v| (a.key.clone(), v)))
1590 .collect();
1591 Node::Include(IncludeNode { path, props, slot })
1592}
1593
1594fn jsx_build_embed(attrs: Vec<JsxAttr>) -> Node {
1595 let src = attrs
1596 .iter()
1597 .find(|a| matches!(a.key.as_str(), "src" | "path"))
1598 .and_then(|a| a.as_str())
1599 .unwrap_or("")
1600 .to_string();
1601 let adapter = attrs
1602 .iter()
1603 .find(|a| a.key == "adapter")
1604 .and_then(|a| a.as_str())
1605 .map(|s| s.to_string());
1606 let props = attrs
1607 .iter()
1608 .filter(|a| !matches!(a.key.as_str(), "src" | "path" | "adapter"))
1609 .filter_map(|a| a.as_expr().map(|v| (a.key.clone(), v)))
1610 .collect();
1611 Node::Embed(EmbedNode {
1612 src,
1613 adapter,
1614 props,
1615 })
1616}
1617
1618fn jsx_build_let(attrs: Vec<JsxAttr>, is_default: bool) -> LetDecl {
1619 let name = attrs
1620 .iter()
1621 .find(|a| a.key == "name")
1622 .and_then(|a| a.as_str())
1623 .unwrap_or("")
1624 .to_string();
1625 let expr = attrs
1626 .iter()
1627 .find(|a| a.key == "value")
1628 .and_then(|a| a.as_expr())
1629 .unwrap_or_default();
1630 LetDecl {
1631 name,
1632 expr,
1633 is_default,
1634 }
1635}
1636
1637fn jsx_parse_attrs<'a>(
1640 norm_root: &'a str,
1641 src: &'a str,
1642) -> Result<(Vec<JsxAttr>, &'a str, bool), RawParseError> {
1643 let mut attrs = Vec::new();
1644 let mut rest = src.trim_start();
1645 let mut self_closing = false;
1646
1647 loop {
1648 rest = rest.trim_start();
1649 if rest.is_empty() {
1650 return Err(jsx_err(norm_root, rest, "unclosed JSX tag"));
1651 }
1652 if rest.starts_with("/>") {
1653 self_closing = true;
1654 rest = &rest[2..];
1655 break;
1656 }
1657 if rest.starts_with('>') {
1658 rest = &rest[1..];
1659 break;
1660 }
1661
1662 let key_end = rest
1663 .find(|c: char| c.is_whitespace() || c == '=' || c == '>' || c == '/')
1664 .unwrap_or(rest.len());
1665 if key_end == 0 {
1666 rest = &rest[1..];
1667 continue;
1668 }
1669 let key = rest[..key_end].to_string();
1670 rest = rest[key_end..].trim_start();
1671
1672 if rest.starts_with('=') {
1673 rest = rest[1..].trim_start();
1674 let (value, next) = jsx_attr_value(norm_root, rest)?;
1675 attrs.push(JsxAttr { key, value });
1676 rest = next;
1677 } else {
1678 attrs.push(JsxAttr {
1679 key,
1680 value: JsxAttrValue::Bool(true),
1681 });
1682 }
1683 }
1684
1685 Ok((attrs, rest, self_closing))
1686}
1687
1688fn jsx_attr_value<'a>(
1689 norm_root: &'a str,
1690 src: &'a str,
1691) -> Result<(JsxAttrValue, &'a str), RawParseError> {
1692 if src.starts_with('"') {
1693 let mut i = 1;
1694 let bytes = src.as_bytes();
1695 while i < bytes.len() {
1696 match bytes[i] {
1697 b'\\' => i += 2,
1698 b'"' => {
1699 let content = src[1..i].replace("\\\"", "\"");
1700 return Ok((JsxAttrValue::Str(content), &src[i + 1..]));
1701 }
1702 _ => i += 1,
1703 }
1704 }
1705 let inner = src.strip_prefix('"').unwrap_or("");
1706 Ok((JsxAttrValue::Str(inner.replace("\\\"", "\"")), ""))
1707 } else if src.starts_with('\'') {
1708 let inner = src.strip_prefix('\'').unwrap_or(src);
1709 let end = inner.find('\'').unwrap_or(inner.len());
1710 Ok((
1711 JsxAttrValue::Str(inner[..end].to_string()),
1712 &inner[end + 1..],
1713 ))
1714 } else if src.starts_with('{') {
1715 let (expr, rest) = jsx_brace_expr(norm_root, src)?;
1716 Ok((JsxAttrValue::Expr(expr), rest))
1717 } else {
1718 let end = src
1719 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
1720 .unwrap_or(src.len());
1721 let val = &src[..end];
1722 let value = match val {
1723 "true" => JsxAttrValue::Bool(true),
1724 "false" => JsxAttrValue::Bool(false),
1725 other => JsxAttrValue::Str(other.to_string()),
1726 };
1727 Ok((value, &src[end..]))
1728 }
1729}
1730
1731fn jsx_brace_expr<'a>(
1732 norm_root: &'a str,
1733 src: &'a str,
1734) -> Result<(String, &'a str), RawParseError> {
1735 let src = src.trim_start();
1736 if !src.starts_with('{') {
1737 return Err(jsx_err(
1738 norm_root,
1739 src,
1740 format!("expected '{{', got: {}", &src[..src.len().min(10)]),
1741 ));
1742 }
1743 let mut depth = 0usize;
1744 for (i, c) in src.char_indices() {
1745 match c {
1746 '{' => depth += 1,
1747 '}' => {
1748 depth -= 1;
1749 if depth == 0 {
1750 let expr = src[1..i].trim().to_string();
1751 return Ok((expr, &src[i + 1..]));
1752 }
1753 }
1754 _ => {}
1755 }
1756 }
1757 Err(jsx_err(norm_root, src, "unclosed '{' in JSX expression"))
1758}
1759
1760fn jsx_text_node(src: &str) -> (Option<Node>, &str) {
1762 let end = src.find('<').unwrap_or(src.len());
1763 if end == 0 {
1764 return (None, src);
1765 }
1766 let text = &src[..end];
1767 let trimmed = text.trim();
1768 if trimmed.is_empty() {
1769 return (None, &src[end..]);
1770 }
1771 let parts = parse_text_template(trimmed);
1772 (Some(Node::Text(parts)), &src[end..])
1773}
1774
1775fn jsx_close<'a>(norm_root: &str, src: &'a str, tag: &str) -> Result<&'a str, RawParseError> {
1776 let src = src.trim_start();
1777 let prefix = format!("</{}", tag);
1778 if let Some(rest) = src.strip_prefix(&prefix) {
1779 let rest = rest.trim_start();
1780 if let Some(rest) = rest.strip_prefix('>') {
1781 return Ok(rest);
1782 }
1783 }
1784 Err(jsx_err(
1785 norm_root,
1786 src,
1787 format!("expected </{}>, got: {}", tag, &src[..src.len().min(40)]),
1788 ))
1789}
1790
1791fn normalize_fullwidth_braces(s: &str) -> String {
1792 s.replace('\u{FF5B}', "{").replace('\u{FF5D}', "}")
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797 use super::*;
1798
1799 #[test]
1800 fn strip_structural_indent_basic() {
1801 assert_eq!(strip_structural_indent(" hello", 4), "hello");
1802 assert_eq!(strip_structural_indent(" hello", 2), " hello");
1803 assert_eq!(strip_structural_indent(" hello", 0), " hello");
1804 assert_eq!(strip_structural_indent("hi", 4), "hi");
1805 assert_eq!(strip_structural_indent("", 4), "");
1806 }
1807
1808 fn text_from_node(node: &Node) -> String {
1809 let Node::Text(parts) = node else {
1810 panic!("expected Text node, got: {node:?}")
1811 };
1812 parts
1813 .iter()
1814 .map(|p| match p {
1815 crate::ast::TextPart::Literal(s) => s.clone(),
1816 crate::ast::TextPart::Expr(e) => format!("{{{e}}}"),
1817 })
1818 .collect()
1819 }
1820
1821 #[test]
1822 fn parse_when_attribute_suffix_braced_condition_with_equals() {
1823 let (c, v) = parse_when_attribute_suffix(r#"{a == b}="x y z""#).unwrap();
1824 assert_eq!(c, "a == b");
1825 assert_eq!(v, r#""x y z""#);
1826 }
1827
1828 #[test]
1829 fn parse_when_attribute_suffix_simple_ident() {
1830 let (c, v) = parse_when_attribute_suffix(r#"active=bg-red-500"#).unwrap();
1831 assert_eq!(c, "active");
1832 assert_eq!(v, "bg-red-500");
1833 }
1834
1835 #[test]
1836 fn when_attribute_indent_expands_to_conditional_classes() {
1837 let nodes = parse_template(
1838 r#"div base when:{flag}="a b" when:{!flag}="c"
1839 "hi""#,
1840 )
1841 .unwrap();
1842 let Node::Element(el) = &nodes[0] else {
1843 panic!("expected element");
1844 };
1845 assert_eq!(el.classes, vec!["base".to_string()]);
1846 assert_eq!(el.conditional_classes.len(), 3);
1847 assert_eq!(el.conditional_classes[0].class, "a");
1848 assert_eq!(el.conditional_classes[0].condition, "flag");
1849 assert_eq!(el.conditional_classes[1].class, "b");
1850 assert_eq!(el.conditional_classes[2].class, "c");
1851 assert_eq!(el.conditional_classes[2].condition, "!flag");
1852 }
1853
1854 #[test]
1855 fn when_attribute_jsx_expands_to_conditional_classes() {
1856 let nodes =
1857 parse_template(r#"<div class="base" when:{active}="font-bold text-white"></div>"#)
1858 .unwrap();
1859 let Node::Element(el) = &nodes[0] else {
1860 panic!("expected element");
1861 };
1862 assert_eq!(el.classes, vec!["base".to_string()]);
1863 assert_eq!(el.conditional_classes.len(), 2);
1864 assert_eq!(el.conditional_classes[0].class, "font-bold");
1865 assert_eq!(el.conditional_classes[0].condition, "active");
1866 assert_eq!(el.conditional_classes[1].class, "text-white");
1867 }
1868
1869 #[test]
1870 fn embed_indent_parses_src_adapter_and_props() {
1871 let nodes =
1872 parse_template(r#"embed ./islands/wave.ts adapter="module" title="Wave" count={n}"#)
1873 .unwrap();
1874 let Node::Embed(embed) = &nodes[0] else {
1875 panic!("expected embed");
1876 };
1877 assert_eq!(embed.src, "./islands/wave.ts");
1878 assert_eq!(embed.adapter.as_deref(), Some("module"));
1879 assert_eq!(embed.props.len(), 2);
1880 assert_eq!(
1881 embed.props[0],
1882 ("title".to_string(), "\"Wave\"".to_string())
1883 );
1884 assert_eq!(embed.props[1], ("count".to_string(), "n".to_string()));
1885 }
1886
1887 #[test]
1888 fn island_jsx_parses_src_adapter_and_props() {
1889 let nodes = parse_template(
1890 r#"<island src="./islands/wave.ts" adapter="module" title="Wave" count={n} />"#,
1891 )
1892 .unwrap();
1893 let Node::Embed(embed) = &nodes[0] else {
1894 panic!("expected embed");
1895 };
1896 assert_eq!(embed.src, "./islands/wave.ts");
1897 assert_eq!(embed.adapter.as_deref(), Some("module"));
1898 assert_eq!(embed.props.len(), 2);
1899 assert_eq!(
1900 embed.props[0],
1901 ("title".to_string(), "\"Wave\"".to_string())
1902 );
1903 assert_eq!(embed.props[1], ("count".to_string(), "n".to_string()));
1904 }
1905
1906 #[test]
1907 fn multiline_string_preserves_inner_indent() {
1908 let template = "pre\n \"line one\n indented line\n more indented\"";
1913 let nodes = parse_template(template).unwrap();
1914 let Node::Element(el) = &nodes[0] else {
1915 panic!("expected element")
1916 };
1917 let text = text_from_node(&el.children[0]);
1918 assert!(text.contains("indented line"), "got: {text:?}");
1919 let cont_indent = text
1921 .lines()
1922 .nth(1)
1923 .map(|l| l.len() - l.trim_start().len())
1924 .unwrap_or(0);
1925 assert_eq!(
1926 cont_indent, 0,
1927 "continuation at same level should have 0 leading spaces, got: {text:?}"
1928 );
1929 let deep_indent = text
1931 .lines()
1932 .nth(2)
1933 .map(|l| l.len() - l.trim_start().len())
1934 .unwrap_or(0);
1935 assert_eq!(
1936 deep_indent, 2,
1937 "deeper line should have 2 preserved leading spaces, got: {text:?}"
1938 );
1939 }
1940
1941 #[test]
1942 fn multiline_string_preserves_extra_indent() {
1943 let template = "pre\n \"first\n extra\"";
1946 let nodes = parse_template(template).unwrap();
1947 let Node::Element(el) = &nodes[0] else {
1948 panic!("expected element")
1949 };
1950 let text = text_from_node(&el.children[0]);
1951 assert!(
1952 text.contains(" extra"),
1953 "extra indent should be preserved, got: {text:?}"
1954 );
1955 }
1956
1957 #[test]
1958 fn quoted_line_unescapes_backslash_sequences() {
1959 let nodes = parse_template("pre\n \"a\\nb\\tc\\\\d\"").unwrap();
1960 let Node::Element(el) = &nodes[0] else {
1961 panic!("expected element");
1962 };
1963 let text = text_from_node(&el.children[0]);
1964 assert_eq!(text, "a\nb\tc\\d");
1965 }
1966
1967 #[test]
1968 fn unescape_crepus_text_literal_accepts_common_escapes() {
1969 assert_eq!(
1970 unescape_crepus_text_literal(r#"line\n\t\"quote""#),
1971 "line\n\t\"quote\""
1972 );
1973 }
1974}