Skip to main content

orbit_md/components/
parser.rs

1//! JSX-style component tag scanner for Markdown bodies.
2//!
3//! Detects PascalCase tags (React-like components) and leaves lowercase HTML
4//! tags untouched.
5
6use std::collections::HashMap;
7use std::ops::Range;
8
9use crate::error::PageError;
10
11/// A parsed component invocation inside Markdown source.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ParsedComponent {
14    /// Component name (PascalCase), e.g. `Alert`.
15    pub name: String,
16    /// Attribute key/value pairs from the opening tag.
17    pub attrs: HashMap<String, String>,
18    /// Inner Markdown for block components; empty for self-closing tags.
19    pub inner: String,
20    /// Whether the tag is self-closing (`<Button />`).
21    pub self_closing: bool,
22    /// Byte range of the entire tag invocation in the source string.
23    pub span: Range<usize>,
24}
25
26/// Returns `true` when `name` is a valid PascalCase component identifier.
27pub 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
38/// Finds the next PascalCase component tag at or after `from`.
39pub 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    // Closing tags and HTML comments are not components.
63    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
226/// Validates a parsed component and returns a [`PageError`] on malformed tags.
227pub 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}