use std::collections::BTreeMap;
use std::path::Path;
use minijinja::{context, Environment};
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct Component {
pub name: String,
pub template: String,
pub island_js: Option<String>,
pub style_css: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DirectiveContext {
pub attrs: BTreeMap<String, String>,
pub content: String,
pub label: String,
pub id: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ComponentError {
#[error("component `{name}`: template render failed: {source}")]
Render {
name: String,
#[source]
source: minijinja::Error,
},
}
impl Component {
pub fn from_parts(
name: impl Into<String>,
template: impl Into<String>,
island_js: Option<String>,
style_css: Option<String>,
) -> Self {
Self {
name: name.into(),
template: template.into(),
island_js,
style_css,
}
}
pub fn render(&self, ctx: &DirectiveContext) -> Result<String, ComponentError> {
let mut env = Environment::new();
env.add_template("c.html", &self.template)
.map_err(|e| ComponentError::Render {
name: self.name.clone(),
source: e,
})?;
let tmpl = env
.get_template("c.html")
.expect("template just added under this name");
tmpl.render(context! {
attrs => &ctx.attrs,
content => &ctx.content,
label => &ctx.label,
id => &ctx.id,
})
.map_err(|e| ComponentError::Render {
name: self.name.clone(),
source: e,
})
}
}
#[derive(Debug, Clone, Default)]
pub struct Registry {
map: BTreeMap<String, Component>,
}
impl Registry {
pub fn empty() -> Self {
Self::default()
}
pub fn insert(&mut self, c: Component) {
self.map.insert(c.name.clone(), c);
}
pub fn get(&self, name: &str) -> Option<&Component> {
self.map.get(name)
}
pub fn contains(&self, name: &str) -> bool {
self.map.contains_key(name)
}
pub fn islands(&self) -> Vec<&Component> {
self.map
.values()
.filter(|c| c.island_js.is_some())
.collect()
}
pub fn styles(&self) -> Vec<&Component> {
self.map
.values()
.filter(|c| c.style_css.is_some())
.collect()
}
}
pub fn discover(dir: &Path) -> std::io::Result<Vec<Component>> {
let mut out = Vec::new();
let rd = match std::fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e),
};
let mut names: Vec<String> = Vec::new();
for entry in rd {
let entry = entry?;
if entry.file_type()?.is_dir() {
names.push(entry.file_name().to_string_lossy().into_owned());
}
}
names.sort();
for name in names {
let base = dir.join(&name);
let template = match std::fs::read_to_string(base.join("template.html")) {
Ok(t) => t,
Err(_) => continue, };
let island_js = std::fs::read_to_string(base.join("island.js")).ok();
let style_css = std::fs::read_to_string(base.join("style.css")).ok();
out.push(Component::from_parts(name, template, island_js, style_css));
}
Ok(out)
}
pub fn build_registry(builtins: Vec<Component>, project_dir: &Path) -> std::io::Result<Registry> {
let mut reg = Registry::empty();
for c in builtins {
reg.insert(c);
}
for c in discover(project_dir)? {
reg.insert(c);
}
Ok(reg)
}
#[cfg(test)]
mod registry_tests {
use super::*;
fn write_component(root: &Path, name: &str, tpl: &str) {
let d = root.join(name);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("template.html"), tpl).unwrap();
}
#[test]
fn discovers_project_components_sorted_and_requires_template() {
let dir = tempfile::tempdir().unwrap();
write_component(dir.path(), "note", "<div>{{ content | safe }}</div>");
std::fs::create_dir_all(dir.path().join("empty")).unwrap();
let comps = discover(dir.path()).unwrap();
assert_eq!(comps.len(), 1);
assert_eq!(comps[0].name, "note");
}
#[test]
fn missing_components_dir_is_empty_not_error() {
let dir = tempfile::tempdir().unwrap();
let comps = discover(&dir.path().join("nope")).unwrap();
assert!(comps.is_empty());
}
#[test]
fn project_component_overrides_builtin_of_same_name() {
let dir = tempfile::tempdir().unwrap();
write_component(
dir.path(),
"callout",
"<div class=\"project-callout\">{{ content | safe }}</div>",
);
let builtin = Component::from_parts(
"callout",
"<div class=\"builtin-callout\"></div>",
None,
None,
);
let reg = build_registry(vec![builtin], dir.path()).unwrap();
let c = reg.get("callout").unwrap();
assert!(c.template.contains("project-callout"));
assert!(!c.template.contains("builtin-callout"));
}
#[test]
fn picks_up_island_and_style_when_present() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path().join("rating");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("template.html"), "<div></div>").unwrap();
std::fs::write(d.join("island.js"), "Alpine.data('r',()=>({}))").unwrap();
std::fs::write(d.join("style.css"), ".r{}").unwrap();
let comps = discover(dir.path()).unwrap();
assert!(comps[0].island_js.is_some());
assert!(comps[0].style_css.is_some());
let mut reg = Registry::empty();
reg.insert(comps.into_iter().next().unwrap());
assert_eq!(reg.islands().len(), 1);
assert_eq!(reg.styles().len(), 1);
}
}
#[cfg(test)]
mod render_tests {
use super::*;
fn attrs(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn renders_block_component_with_attrs_and_content() {
let c = Component::from_parts(
"callout",
"<aside class=\"c--{{ attrs.type | default('note') }}\">\
{% if attrs.title %}<p>{{ attrs.title }}</p>{% endif %}\
<div>{{ content | safe }}</div></aside>",
None,
None,
);
let html = c
.render(&DirectiveContext {
attrs: attrs(&[("type", "warning"), ("title", "Back up first")]),
content: "<p>destructive</p>".into(),
label: "".into(),
id: "d0".into(),
})
.unwrap();
assert!(html.contains("c--warning"));
assert!(html.contains("Back up first"));
assert!(html.contains("<p>destructive</p>")); }
#[test]
fn renders_leaf_component_with_label() {
let c = Component::from_parts(
"youtube",
"<figure><iframe title=\"{{ label }}\" \
src=\"https://yt/embed/{{ attrs.id }}\"></iframe><figcaption>{{ label }}</figcaption></figure>",
None,
None,
);
let html = c
.render(&DirectiveContext {
attrs: attrs(&[("id", "abc123")]),
content: "".into(),
label: "Intro to docgen".into(),
id: "d1".into(),
})
.unwrap();
assert!(html.contains("embed/abc123"));
assert!(html.contains("Intro to docgen"));
}
#[test]
fn attrs_and_label_are_html_escaped() {
let c = Component::from_parts(
"x",
"<i title=\"{{ label }}\">{{ attrs.a }}</i>",
None,
None,
);
let html = c
.render(&DirectiveContext {
attrs: attrs(&[("a", "<script>")]),
content: "".into(),
label: "a&b".into(),
id: "d".into(),
})
.unwrap();
assert!(html.contains("<script>"));
assert!(html.contains("a&b"));
}
}