orbit_md/components/
mod.rs1mod 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
33pub struct ComponentRegistry {
37 handlebars: Handlebars<'static>,
38 template_names: Vec<String>,
39}
40
41impl ComponentRegistry {
42 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 pub fn template_names(&self) -> &[String] {
87 &self.template_names
88 }
89
90 pub fn has_component(&self, name: &str) -> bool {
92 self.handlebars.has_template(name)
93 }
94
95 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
134pub 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
192pub fn markdown_fragment_to_html(markdown: &str) -> String {
194 if markdown.is_empty() {
195 return String::new();
196 }
197
198 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
210fn 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, ®istry, 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, ®istry, 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 />", ®istry, Path::new("test.md"));
292 assert!(result.is_err());
293 }
294}