Skip to main content

docgen_components/
lib.rs

1//! Custom-component directive registry. A `Component` is a directory
2//! `<name>/{template.html, island.js?, style.css?}`. Built-ins ship embedded in
3//! `docgen-assets` and load through the SAME `Component::from_parts` path that
4//! reads project components — so built-ins dogfood the mechanism. A project
5//! component overrides a built-in of the same name.
6
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use minijinja::{context, Environment};
11use serde::Serialize;
12
13/// One loaded component.
14#[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/// The render inputs for a single directive instance.
23#[derive(Debug, Clone, Serialize)]
24pub struct DirectiveContext {
25    pub attrs: BTreeMap<String, String>,
26    /// Rendered inner HTML (block form); empty for leaf form.
27    pub content: String,
28    /// The `[label]` text (leaf form); empty for block form.
29    pub label: String,
30    /// Unique per-instance id for island wiring.
31    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    /// Build a component from its raw parts (used by BOTH project discovery and
46    /// the embedded built-in loader).
47    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    /// Render this component for one directive instance to HTML.
62    ///
63    /// The template is registered under a `.html` name so minijinja's default
64    /// auto-escape callback HTML-escapes `{{ attrs.x }}` / `{{ label }}`. The
65    /// already-rendered inner markdown is exposed as `content`, so a template
66    /// author writes `{{ content | safe }}` to splice block content raw.
67    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/// A name → component map. Built-ins inserted first, project components last
91/// (so a project `<name>` overrides a built-in `<name>`).
92#[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    /// Insert (or override) a component by its `name`.
103    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    /// All components with an `island.js`, in BTreeMap name-key order — the
116    /// concatenation order for the emitted `components.js` (deterministic).
117    pub fn islands(&self) -> Vec<&Component> {
118        self.map
119            .values()
120            .filter(|c| c.island_js.is_some())
121            .collect()
122    }
123
124    /// All components with a `style.css`, in BTreeMap name-key order (deterministic).
125    pub fn styles(&self) -> Vec<&Component> {
126        self.map
127            .values()
128            .filter(|c| c.style_css.is_some())
129            .collect()
130    }
131}
132
133/// Read every `<name>/` subdir of `dir` into components. `template.html` is
134/// required; a subdir without it is skipped (with no error — a stray dir is not
135/// fatal). Missing `dir` → no components (empty). Deterministic (sorted names).
136pub 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, // no template.html → not a component
156        };
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
164/// Build the full registry: embedded built-ins first, then project components
165/// from `project_dir` (which override built-ins by name).
166pub 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        // a stray dir with no template.html is ignored
192        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>")); // content raw
275    }
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("&lt;script&gt;"));
315        assert!(html.contains("a&amp;b"));
316    }
317}