Skip to main content

orbit_md/components/
mod.rs

1//! React-like Markdown component layer.
2//!
3//! Authors invoke reusable UI pieces with JSX-style tags inside `.md` files.
4//! Components are Handlebars templates in `components/*.hbs`.
5//!
6//! # Island contract (future hydration)
7//!
8//! When a component tag includes `client="load"`, the rendered HTML is wrapped:
9//!
10//! ```html
11//! <div data-orbit-island="Counter" data-props='{"initial":"0"}'>
12//!   <!-- static fallback from Counter.hbs -->
13//! </div>
14//! ```
15//!
16//! A future `/islands/loader.js` can read these attributes and mount widgets.
17
18mod parser;
19
20use std::collections::HashMap;
21use std::fs;
22use std::path::Path;
23
24use handlebars::Handlebars;
25use pulldown_cmark::{Options, Parser, html};
26use serde_json::{Map, Value};
27
28pub use parser::{ParsedComponent, find_next_component, is_component_name};
29
30use crate::config::Config;
31use crate::error::{OrbitError, PageError};
32
33/// Pre-compiled Handlebars templates for Markdown components.
34///
35/// Immutable and `Sync` — shared across rayon workers without locking.
36pub struct ComponentRegistry {
37    handlebars: Handlebars<'static>,
38    template_names: Vec<String>,
39}
40
41impl ComponentRegistry {
42    /// Loads every `.hbs` file from `config.components_dir`.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`OrbitError::Template`] when templates cannot be read or compiled.
47    pub fn from_config(config: &Config) -> Result<Self, OrbitError> {
48        let mut handlebars = Handlebars::new();
49        handlebars.set_strict_mode(false);
50        let mut template_names = Vec::new();
51
52        if config.components_dir.exists() {
53            for entry in fs::read_dir(&config.components_dir).map_err(|source| OrbitError::Io {
54                path: config.components_dir.clone(),
55                source,
56            })? {
57                let entry = entry.map_err(|source| OrbitError::Io {
58                    path: config.components_dir.clone(),
59                    source,
60                })?;
61                let path = entry.path();
62                if path.extension().is_some_and(|ext| ext == "hbs") {
63                    let file_stem = path
64                        .file_stem()
65                        .and_then(|n| n.to_str())
66                        .ok_or_else(|| OrbitError::Template("invalid component filename".into()))?;
67                    let source = fs::read_to_string(&path).map_err(|source| OrbitError::Io {
68                        path: path.clone(),
69                        source,
70                    })?;
71                    handlebars
72                        .register_template_string(file_stem, source)
73                        .map_err(|err| OrbitError::Template(err.to_string()))?;
74                    template_names.push(file_stem.to_owned());
75                }
76            }
77        }
78
79        Ok(Self {
80            handlebars,
81            template_names,
82        })
83    }
84
85    /// Returns the names of loaded component templates.
86    pub fn template_names(&self) -> &[String] {
87        &self.template_names
88    }
89
90    /// Returns `true` when a component template is registered.
91    pub fn has_component(&self, name: &str) -> bool {
92        self.handlebars.has_template(name)
93    }
94
95    /// Renders a component with props and pre-compiled children HTML.
96    pub fn render(
97        &self,
98        name: &str,
99        attrs: &HashMap<String, String>,
100        children_html: &str,
101        path: &Path,
102    ) -> Result<String, PageError> {
103        if !self.has_component(name) {
104            return Err(PageError::new(
105                path,
106                format!("unknown component `{name}` — expected components/{name}.hbs"),
107            ));
108        }
109
110        let mut context = Map::new();
111        for (key, value) in attrs {
112            context.insert(key.clone(), Value::String(value.clone()));
113        }
114        context.insert(
115            "children".to_owned(),
116            Value::String(children_html.to_owned()),
117        );
118
119        let html = self
120            .handlebars
121            .render(name, &Value::Object(context))
122            .map_err(|err| {
123                PageError::new(path, format!("component `{name}` render failed: {err}"))
124            })?;
125
126        if attrs.contains_key("client") {
127            Ok(wrap_island(name, attrs, &html))
128        } else {
129            Ok(html)
130        }
131    }
132}
133
134/// Expands JSX-style component tags in `body` into HTML fragments.
135///
136/// Nested components are expanded inside-out. Slot Markdown is compiled to
137/// HTML before being passed as the `children` prop.
138///
139/// # Examples
140///
141/// ```no_run
142/// use orbit_md::components::{ComponentRegistry, expand_components};
143/// use orbit_md::config::Config;
144/// use std::path::Path;
145///
146/// let config = Config::default();
147/// let registry = ComponentRegistry::from_config(&config).unwrap();
148/// let body = r#"<Alert type="info">Hello</Alert>"#;
149/// let expanded = expand_components(body, &registry, Path::new("page.md")).unwrap();
150/// assert!(expanded.contains("alert"));
151/// ```
152pub fn expand_components(
153    body: &str,
154    registry: &ComponentRegistry,
155    path: &Path,
156) -> Result<String, PageError> {
157    expand_fragment(body, registry, path)
158}
159
160fn expand_fragment(
161    body: &str,
162    registry: &ComponentRegistry,
163    path: &Path,
164) -> Result<String, PageError> {
165    let mut output = String::with_capacity(body.len());
166    let mut cursor = 0;
167
168    while cursor < body.len() {
169        let Some(component) = find_next_component(body, cursor) else {
170            output.push_str(&body[cursor..]);
171            break;
172        };
173
174        output.push_str(&body[cursor..component.span.start]);
175        parser::validate_component(&component, path)?;
176
177        let children_md = if component.self_closing {
178            String::new()
179        } else {
180            expand_fragment(&component.inner, registry, path)?
181        };
182
183        let children_html = markdown_fragment_to_html(&children_md);
184        let rendered = registry.render(&component.name, &component.attrs, &children_html, path)?;
185        output.push_str(&rendered);
186        cursor = component.span.end;
187    }
188
189    Ok(output)
190}
191
192/// Compiles a Markdown fragment to an HTML string for component slots.
193pub fn markdown_fragment_to_html(markdown: &str) -> String {
194    if markdown.is_empty() {
195        return String::new();
196    }
197
198    // NOTE: keep options aligned with the main parser for consistent output.
199    let options = Options::ENABLE_STRIKETHROUGH
200        | Options::ENABLE_TABLES
201        | Options::ENABLE_FOOTNOTES
202        | Options::ENABLE_TASKLISTS;
203
204    let parser = Parser::new_ext(markdown, options);
205    let mut html = String::with_capacity(markdown.len().saturating_mul(2));
206    html::push_html(&mut html, parser);
207    html
208}
209
210/// Wraps rendered HTML in the island contract for future client hydration.
211fn wrap_island(name: &str, attrs: &HashMap<String, String>, html: &str) -> String {
212    let mut props = Map::new();
213    for (key, value) in attrs {
214        if key == "client" {
215            continue;
216        }
217        props.insert(key.clone(), Value::String(value.clone()));
218    }
219
220    let props_json =
221        serde_json::to_string(&Value::Object(props)).unwrap_or_else(|_| "{}".to_owned());
222
223    format!(r#"<div data-orbit-island="{name}" data-props='{props_json}'>{html}</div>"#)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use std::path::Path;
230
231    fn test_registry(dir: &Path) -> ComponentRegistry {
232        let config = Config {
233            components_dir: dir.to_path_buf(),
234            ..Config::default()
235        };
236        ComponentRegistry::from_config(&config).unwrap()
237    }
238
239    fn write_component(dir: &Path, name: &str, source: &str) {
240        std::fs::write(dir.join(format!("{name}.hbs")), source).unwrap();
241    }
242
243    #[test]
244    fn expands_block_and_self_closing_components() {
245        let dir = tempfile::tempdir().unwrap();
246        write_component(
247            dir.path(),
248            "Alert",
249            r#"<div class="alert alert-{{type}}">{{#if title}}<strong>{{title}}</strong>{{/if}}{{{children}}}</div>"#,
250        );
251        write_component(
252            dir.path(),
253            "Button",
254            r#"<a class="btn" href="{{href}}">{{label}}</a>"#,
255        );
256
257        let registry = test_registry(dir.path());
258        let body = r#"<Alert type="info" title="Hi">Hello **world**</Alert>
259
260<Button href="/docs" label="Go" />"#;
261
262        let expanded = expand_components(body, &registry, Path::new("test.md")).unwrap();
263        assert!(expanded.contains(r#"class="alert alert-info""#));
264        assert!(expanded.contains("<strong>Hi</strong>"));
265        assert!(expanded.contains("<strong>world</strong>"));
266        assert!(expanded.contains(r#"class="btn" href="/docs""#));
267    }
268
269    #[test]
270    fn island_wrapper_adds_data_attributes() {
271        let dir = tempfile::tempdir().unwrap();
272        write_component(
273            dir.path(),
274            "Counter",
275            r#"<span class="counter">{{initial}}</span>"#,
276        );
277
278        let registry = test_registry(dir.path());
279        let body = r#"<Counter client="load" initial="0" />"#;
280        let expanded = expand_components(body, &registry, Path::new("test.md")).unwrap();
281
282        assert!(expanded.contains(r#"data-orbit-island="Counter""#));
283        assert!(expanded.contains(r#"data-props='{"initial":"0"}'"#));
284        assert!(expanded.contains(r#"<span class="counter">0</span>"#));
285    }
286
287    #[test]
288    fn unknown_component_returns_page_error() {
289        let dir = tempfile::tempdir().unwrap();
290        let registry = test_registry(dir.path());
291        let result = expand_components("<Missing />", &registry, Path::new("test.md"));
292        assert!(result.is_err());
293    }
294}