orbit_md/components/
parser.rs1use std::collections::HashMap;
7use std::ops::Range;
8
9use crate::error::PageError;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ParsedComponent {
14 pub name: String,
16 pub attrs: HashMap<String, String>,
18 pub inner: String,
20 pub self_closing: bool,
22 pub span: Range<usize>,
24}
25
26pub fn is_component_name(name: &str) -> bool {
28 let mut chars = name.chars();
29 let Some(first) = chars.next() else {
30 return false;
31 };
32 if !first.is_ascii_uppercase() {
33 return false;
34 }
35 name.chars().all(|c| c.is_ascii_alphanumeric())
36}
37
38pub fn find_next_component(source: &str, from: usize) -> Option<ParsedComponent> {
40 let bytes = source.as_bytes();
41 let mut index = from;
42
43 while index < bytes.len() {
44 if bytes[index] != b'<' {
45 index += 1;
46 continue;
47 }
48
49 if let Some(component) = try_parse_component(source, index) {
50 return Some(component);
51 }
52
53 index += 1;
54 }
55
56 None
57}
58
59fn try_parse_component(source: &str, open: usize) -> Option<ParsedComponent> {
60 let rest = &source[open + 1..];
61
62 if rest.starts_with('/') || rest.starts_with('!') {
64 return None;
65 }
66
67 let (name, after_name) = read_identifier(rest)?;
68 if !is_component_name(name) {
69 return None;
70 }
71
72 let (attrs, after_attrs) = parse_attributes(after_name)?;
73 let after_attrs = skip_whitespace(after_attrs);
74
75 if after_attrs.starts_with("/>") {
76 let end = open + 1 + (after_attrs.as_ptr() as usize - rest.as_ptr() as usize) + 2;
77 return Some(ParsedComponent {
78 name: name.to_owned(),
79 attrs,
80 inner: String::new(),
81 self_closing: true,
82 span: open..end,
83 });
84 }
85
86 if !after_attrs.starts_with('>') {
87 return None;
88 }
89
90 let content_start = open + 1 + (after_attrs.as_ptr() as usize - rest.as_ptr() as usize) + 1;
91 let close_tag = format!("</{name}>");
92 let (inner, close_start) = read_until_matching_close(source, content_start, name).ok()?;
93 let end = close_start + close_tag.len();
94
95 Some(ParsedComponent {
96 name: name.to_owned(),
97 attrs,
98 inner,
99 self_closing: false,
100 span: open..end,
101 })
102}
103
104fn read_identifier(input: &str) -> Option<(&str, &str)> {
105 let mut end = 0;
106 for (i, ch) in input.char_indices() {
107 if ch.is_ascii_alphanumeric() {
108 end = i + ch.len_utf8();
109 } else {
110 break;
111 }
112 }
113
114 if end == 0 {
115 return None;
116 }
117
118 Some((&input[..end], &input[end..]))
119}
120
121fn skip_whitespace(input: &str) -> &str {
122 input.trim_start()
123}
124
125fn parse_attributes(input: &str) -> Option<(HashMap<String, String>, &str)> {
126 let mut attrs = HashMap::new();
127 let mut cursor = skip_whitespace(input);
128
129 loop {
130 cursor = skip_whitespace(cursor);
131 if cursor.is_empty() || cursor.starts_with('>') || cursor.starts_with('/') {
132 return Some((attrs, cursor));
133 }
134
135 let (key, after_key) = read_attribute_key(cursor)?;
136 cursor = skip_whitespace(after_key);
137
138 if cursor.starts_with('=') {
139 cursor = skip_whitespace(&cursor[1..]);
140 let (value, after_value) = read_attribute_value(cursor)?;
141 attrs.insert(key.to_owned(), value);
142 cursor = after_value;
143 } else {
144 attrs.insert(key.to_owned(), "true".to_owned());
145 }
146 }
147}
148
149fn read_attribute_key(input: &str) -> Option<(&str, &str)> {
150 let mut end = 0;
151 for (i, ch) in input.char_indices() {
152 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
153 end = i + ch.len_utf8();
154 } else {
155 break;
156 }
157 }
158
159 if end == 0 {
160 return None;
161 }
162
163 Some((&input[..end], &input[end..]))
164}
165
166fn read_attribute_value(input: &str) -> Option<(String, &str)> {
167 if let Some(rest) = input.strip_prefix('"') {
168 let end = rest.find('"')?;
169 return Some((rest[..end].to_owned(), &rest[end + 1..]));
170 }
171
172 if let Some(rest) = input.strip_prefix('\'') {
173 let end = rest.find('\'')?;
174 return Some((rest[..end].to_owned(), &rest[end + 1..]));
175 }
176
177 let end = input
178 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
179 .unwrap_or(input.len());
180 Some((input[..end].to_owned(), &input[end..]))
181}
182
183fn read_until_matching_close(
184 source: &str,
185 start: usize,
186 name: &str,
187) -> Result<(String, usize), ()> {
188 let open_needle = format!("<{name}");
189 let close_tag = format!("</{name}>");
190 let mut depth = 0;
191 let mut cursor = start;
192
193 while cursor < source.len() {
194 let Some(next_open) = source[cursor..].find('<') else {
195 break;
196 };
197 let abs = cursor + next_open;
198
199 if source[abs..].starts_with(&close_tag) {
200 if depth == 0 {
201 let inner = source[start..abs].to_owned();
202 return Ok((inner, abs));
203 }
204 depth -= 1;
205 cursor = abs + close_tag.len();
206 continue;
207 }
208
209 if source[abs..].starts_with(&open_needle) {
210 let after = &source[abs + open_needle.len()..];
211 let boundary = after
212 .chars()
213 .next()
214 .is_none_or(|c| c.is_whitespace() || c == '>' || c == '/');
215 if boundary {
216 depth += 1;
217 }
218 }
219
220 cursor = abs + 1;
221 }
222
223 Err(())
224}
225
226pub fn validate_component(
228 component: &ParsedComponent,
229 path: &std::path::Path,
230) -> Result<(), PageError> {
231 if component.name.is_empty() {
232 return Err(PageError::new(
233 path,
234 "encountered empty component tag name".to_owned(),
235 ));
236 }
237 Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn detects_pascal_case_names() {
246 assert!(is_component_name("Alert"));
247 assert!(is_component_name("Button"));
248 assert!(!is_component_name("div"));
249 assert!(!is_component_name(""));
250 }
251
252 #[test]
253 fn parses_self_closing_component() {
254 let source = r#"Intro <Button href="/docs" label="Go" /> tail"#;
255 let component = find_next_component(source, 0).unwrap();
256 assert_eq!(component.name, "Button");
257 assert!(component.self_closing);
258 assert_eq!(component.attrs.get("href"), Some(&"/docs".to_owned()));
259 assert_eq!(component.attrs.get("label"), Some(&"Go".to_owned()));
260 }
261
262 #[test]
263 fn parses_block_component_with_attributes() {
264 let source = r#"<Alert type="warning" title="Heads up">Hello **world**</Alert>"#;
265 let component = find_next_component(source, 0).unwrap();
266 assert_eq!(component.name, "Alert");
267 assert!(!component.self_closing);
268 assert_eq!(component.inner, "Hello **world**");
269 assert_eq!(component.attrs.get("type"), Some(&"warning".to_owned()));
270 }
271
272 #[test]
273 fn ignores_lowercase_html_tags() {
274 let source = "<div class=\"x\">plain</div>";
275 assert!(find_next_component(source, 0).is_none());
276 }
277
278 #[test]
279 fn parses_nested_components_inner_first() {
280 let source = r#"<Card title="Outer"><Alert type="info">Nested</Alert></Card>"#;
281 let outer = find_next_component(source, 0).unwrap();
282 assert_eq!(outer.name, "Card");
283 assert!(outer.inner.contains("<Alert"));
284 let inner = find_next_component(&outer.inner, 0).unwrap();
285 assert_eq!(inner.name, "Alert");
286 }
287}