1use std::collections::BTreeMap;
8use std::path::Path;
9
10use minijinja::{context, Environment};
11use serde::Serialize;
12
13#[derive(Debug, Clone)]
15pub struct Component {
16 pub name: String,
17 pub template: String,
18 pub island_js: Option<String>,
19 pub style_css: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize)]
24pub struct DirectiveContext {
25 pub attrs: BTreeMap<String, String>,
26 pub content: String,
28 pub label: String,
30 pub id: String,
32}
33
34#[derive(Debug, thiserror::Error)]
35pub enum ComponentError {
36 #[error("component `{name}`: template render failed: {source}")]
37 Render {
38 name: String,
39 #[source]
40 source: minijinja::Error,
41 },
42}
43
44impl Component {
45 pub fn from_parts(
48 name: impl Into<String>,
49 template: impl Into<String>,
50 island_js: Option<String>,
51 style_css: Option<String>,
52 ) -> Self {
53 Self {
54 name: name.into(),
55 template: template.into(),
56 island_js,
57 style_css,
58 }
59 }
60
61 pub fn render(&self, ctx: &DirectiveContext) -> Result<String, ComponentError> {
68 let mut env = Environment::new();
69 env.add_template("c.html", &self.template)
70 .map_err(|e| ComponentError::Render {
71 name: self.name.clone(),
72 source: e,
73 })?;
74 let tmpl = env
75 .get_template("c.html")
76 .expect("template just added under this name");
77 tmpl.render(context! {
78 attrs => &ctx.attrs,
79 content => &ctx.content,
80 label => &ctx.label,
81 id => &ctx.id,
82 })
83 .map_err(|e| ComponentError::Render {
84 name: self.name.clone(),
85 source: e,
86 })
87 }
88}
89
90#[derive(Debug, Clone, Default)]
93pub struct Registry {
94 map: BTreeMap<String, Component>,
95}
96
97impl Registry {
98 pub fn empty() -> Self {
99 Self::default()
100 }
101
102 pub fn insert(&mut self, c: Component) {
104 self.map.insert(c.name.clone(), c);
105 }
106
107 pub fn get(&self, name: &str) -> Option<&Component> {
108 self.map.get(name)
109 }
110
111 pub fn contains(&self, name: &str) -> bool {
112 self.map.contains_key(name)
113 }
114
115 pub fn islands(&self) -> Vec<&Component> {
118 self.map
119 .values()
120 .filter(|c| c.island_js.is_some())
121 .collect()
122 }
123
124 pub fn styles(&self) -> Vec<&Component> {
126 self.map
127 .values()
128 .filter(|c| c.style_css.is_some())
129 .collect()
130 }
131}
132
133pub fn discover(dir: &Path) -> std::io::Result<Vec<Component>> {
137 let mut out = Vec::new();
138 let rd = match std::fs::read_dir(dir) {
139 Ok(rd) => rd,
140 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
141 Err(e) => return Err(e),
142 };
143 let mut names: Vec<String> = Vec::new();
144 for entry in rd {
145 let entry = entry?;
146 if entry.file_type()?.is_dir() {
147 names.push(entry.file_name().to_string_lossy().into_owned());
148 }
149 }
150 names.sort();
151 for name in names {
152 let base = dir.join(&name);
153 let template = match std::fs::read_to_string(base.join("template.html")) {
154 Ok(t) => t,
155 Err(_) => continue, };
157 let island_js = std::fs::read_to_string(base.join("island.js")).ok();
158 let style_css = std::fs::read_to_string(base.join("style.css")).ok();
159 out.push(Component::from_parts(name, template, island_js, style_css));
160 }
161 Ok(out)
162}
163
164pub fn build_registry(builtins: Vec<Component>, project_dir: &Path) -> std::io::Result<Registry> {
167 let mut reg = Registry::empty();
168 for c in builtins {
169 reg.insert(c);
170 }
171 for c in discover(project_dir)? {
172 reg.insert(c);
173 }
174 Ok(reg)
175}
176
177#[cfg(test)]
178mod registry_tests {
179 use super::*;
180
181 fn write_component(root: &Path, name: &str, tpl: &str) {
182 let d = root.join(name);
183 std::fs::create_dir_all(&d).unwrap();
184 std::fs::write(d.join("template.html"), tpl).unwrap();
185 }
186
187 #[test]
188 fn discovers_project_components_sorted_and_requires_template() {
189 let dir = tempfile::tempdir().unwrap();
190 write_component(dir.path(), "note", "<div>{{ content | safe }}</div>");
191 std::fs::create_dir_all(dir.path().join("empty")).unwrap();
193 let comps = discover(dir.path()).unwrap();
194 assert_eq!(comps.len(), 1);
195 assert_eq!(comps[0].name, "note");
196 }
197
198 #[test]
199 fn missing_components_dir_is_empty_not_error() {
200 let dir = tempfile::tempdir().unwrap();
201 let comps = discover(&dir.path().join("nope")).unwrap();
202 assert!(comps.is_empty());
203 }
204
205 #[test]
206 fn project_component_overrides_builtin_of_same_name() {
207 let dir = tempfile::tempdir().unwrap();
208 write_component(
209 dir.path(),
210 "callout",
211 "<div class=\"project-callout\">{{ content | safe }}</div>",
212 );
213 let builtin = Component::from_parts(
214 "callout",
215 "<div class=\"builtin-callout\"></div>",
216 None,
217 None,
218 );
219 let reg = build_registry(vec![builtin], dir.path()).unwrap();
220 let c = reg.get("callout").unwrap();
221 assert!(c.template.contains("project-callout"));
222 assert!(!c.template.contains("builtin-callout"));
223 }
224
225 #[test]
226 fn picks_up_island_and_style_when_present() {
227 let dir = tempfile::tempdir().unwrap();
228 let d = dir.path().join("rating");
229 std::fs::create_dir_all(&d).unwrap();
230 std::fs::write(d.join("template.html"), "<div></div>").unwrap();
231 std::fs::write(d.join("island.js"), "Alpine.data('r',()=>({}))").unwrap();
232 std::fs::write(d.join("style.css"), ".r{}").unwrap();
233 let comps = discover(dir.path()).unwrap();
234 assert!(comps[0].island_js.is_some());
235 assert!(comps[0].style_css.is_some());
236 let mut reg = Registry::empty();
237 reg.insert(comps.into_iter().next().unwrap());
238 assert_eq!(reg.islands().len(), 1);
239 assert_eq!(reg.styles().len(), 1);
240 }
241}
242
243#[cfg(test)]
244mod render_tests {
245 use super::*;
246
247 fn attrs(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
248 pairs
249 .iter()
250 .map(|(k, v)| (k.to_string(), v.to_string()))
251 .collect()
252 }
253
254 #[test]
255 fn renders_block_component_with_attrs_and_content() {
256 let c = Component::from_parts(
257 "callout",
258 "<aside class=\"c--{{ attrs.type | default('note') }}\">\
259 {% if attrs.title %}<p>{{ attrs.title }}</p>{% endif %}\
260 <div>{{ content | safe }}</div></aside>",
261 None,
262 None,
263 );
264 let html = c
265 .render(&DirectiveContext {
266 attrs: attrs(&[("type", "warning"), ("title", "Back up first")]),
267 content: "<p>destructive</p>".into(),
268 label: "".into(),
269 id: "d0".into(),
270 })
271 .unwrap();
272 assert!(html.contains("c--warning"));
273 assert!(html.contains("Back up first"));
274 assert!(html.contains("<p>destructive</p>")); }
276
277 #[test]
278 fn renders_leaf_component_with_label() {
279 let c = Component::from_parts(
280 "youtube",
281 "<figure><iframe title=\"{{ label }}\" \
282 src=\"https://yt/embed/{{ attrs.id }}\"></iframe><figcaption>{{ label }}</figcaption></figure>",
283 None,
284 None,
285 );
286 let html = c
287 .render(&DirectiveContext {
288 attrs: attrs(&[("id", "abc123")]),
289 content: "".into(),
290 label: "Intro to docgen".into(),
291 id: "d1".into(),
292 })
293 .unwrap();
294 assert!(html.contains("embed/abc123"));
295 assert!(html.contains("Intro to docgen"));
296 }
297
298 #[test]
299 fn attrs_and_label_are_html_escaped() {
300 let c = Component::from_parts(
301 "x",
302 "<i title=\"{{ label }}\">{{ attrs.a }}</i>",
303 None,
304 None,
305 );
306 let html = c
307 .render(&DirectiveContext {
308 attrs: attrs(&[("a", "<script>")]),
309 content: "".into(),
310 label: "a&b".into(),
311 id: "d".into(),
312 })
313 .unwrap();
314 assert!(html.contains("<script>"));
315 assert!(html.contains("a&b"));
316 }
317}